neoagent 3.2.1-beta.1 → 3.2.1-beta.11

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 (198) hide show
  1. package/extensions/chrome-browser/background.mjs +318 -88
  2. package/extensions/chrome-browser/http.mjs +136 -0
  3. package/extensions/chrome-browser/protocol.mjs +654 -90
  4. package/flutter_app/lib/main_chat.dart +118 -739
  5. package/flutter_app/lib/main_controller.dart +157 -24
  6. package/flutter_app/lib/main_integrations.dart +607 -8
  7. package/flutter_app/lib/main_models.dart +7 -2
  8. package/flutter_app/lib/main_operations.dart +334 -370
  9. package/flutter_app/lib/main_security.dart +266 -112
  10. package/flutter_app/lib/main_settings.dart +342 -3
  11. package/flutter_app/lib/main_shared.dart +14 -11
  12. package/flutter_app/lib/src/backend_client.dart +97 -0
  13. package/flutter_app/lib/src/desktop_companion_actions.dart +185 -31
  14. package/flutter_app/lib/src/desktop_companion_io.dart +319 -86
  15. package/flutter_app/windows/runner/flutter_window.cpp +143 -32
  16. package/landing/index.html +3 -1
  17. package/lib/manager.js +106 -89
  18. package/lib/schema_migrations.js +115 -13
  19. package/package.json +30 -15
  20. package/runtime/paths.js +49 -5
  21. package/server/db/database.js +2 -2
  22. package/server/guest-agent.cli.package.json +13 -0
  23. package/server/guest_agent.js +85 -40
  24. package/server/http/middleware.js +24 -0
  25. package/server/http/routes.js +12 -6
  26. package/server/public/.last_build_id +1 -1
  27. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  28. package/server/public/flutter_bootstrap.js +2 -2
  29. package/server/public/main.dart.js +81930 -80509
  30. package/server/routes/admin.js +1 -1
  31. package/server/routes/android.js +30 -34
  32. package/server/routes/behavior.js +80 -0
  33. package/server/routes/browser.js +23 -15
  34. package/server/routes/desktop.js +18 -1
  35. package/server/routes/integrations.js +107 -1
  36. package/server/routes/memory.js +1 -0
  37. package/server/routes/settings.js +20 -5
  38. package/server/routes/social_reach.js +12 -3
  39. package/server/routes/social_video.js +4 -0
  40. package/server/services/agents/manager.js +1 -1
  41. package/server/services/ai/capabilityHealth.js +62 -96
  42. package/server/services/ai/compaction.js +7 -2
  43. package/server/services/ai/history.js +45 -6
  44. package/server/services/ai/integrated_tools/http_request.js +8 -0
  45. package/server/services/ai/loop/agent_engine_core.js +452 -176
  46. package/server/services/ai/loop/blank_recovery.js +5 -4
  47. package/server/services/ai/loop/callbacks.js +1 -0
  48. package/server/services/ai/loop/completion_judge.js +291 -8
  49. package/server/services/ai/loop/conversation_loop.js +515 -342
  50. package/server/services/ai/loop/messaging_delivery.js +176 -57
  51. package/server/services/ai/loop/model_call_guard.js +91 -0
  52. package/server/services/ai/loop/model_io.js +20 -45
  53. package/server/services/ai/loop/progress_classification.js +2 -0
  54. package/server/services/ai/loop/tool_dispatch.js +19 -8
  55. package/server/services/ai/loopPolicy.js +48 -21
  56. package/server/services/ai/messagingFallback.js +17 -17
  57. package/server/services/ai/model_discovery.js +227 -0
  58. package/server/services/ai/model_failure_cache.js +108 -0
  59. package/server/services/ai/model_identity.js +71 -0
  60. package/server/services/ai/models.js +68 -163
  61. package/server/services/ai/providerRetry.js +17 -59
  62. package/server/services/ai/provider_selector.js +166 -0
  63. package/server/services/ai/providers/anthropic.js +2 -2
  64. package/server/services/ai/providers/claudeCode.js +21 -33
  65. package/server/services/ai/providers/githubCopilot.js +41 -20
  66. package/server/services/ai/providers/google.js +135 -97
  67. package/server/services/ai/providers/grok.js +4 -3
  68. package/server/services/ai/providers/grokOauth.js +19 -27
  69. package/server/services/ai/providers/nvidia.js +10 -5
  70. package/server/services/ai/providers/ollama.js +111 -84
  71. package/server/services/ai/providers/ollama_stream.js +142 -0
  72. package/server/services/ai/providers/openai.js +39 -5
  73. package/server/services/ai/providers/openaiCodex.js +11 -4
  74. package/server/services/ai/providers/openrouter.js +29 -7
  75. package/server/services/ai/providers/provider_error.js +36 -0
  76. package/server/services/ai/settings.js +26 -2
  77. package/server/services/ai/systemPrompt.js +32 -123
  78. package/server/services/ai/taskAnalysis.js +104 -11
  79. package/server/services/ai/toolEvidence.js +256 -29
  80. package/server/services/ai/tools.js +248 -117
  81. package/server/services/android/controller.js +770 -237
  82. package/server/services/android/process.js +140 -0
  83. package/server/services/android/sdk_download.js +143 -0
  84. package/server/services/android/uia.js +6 -5
  85. package/server/services/artifacts/store.js +24 -0
  86. package/server/services/behavior/config.js +251 -0
  87. package/server/services/behavior/defaults.js +68 -0
  88. package/server/services/behavior/delivery.js +176 -0
  89. package/server/services/behavior/index.js +43 -0
  90. package/server/services/behavior/model_client.js +35 -0
  91. package/server/services/behavior/modules/agent_identity.js +37 -0
  92. package/server/services/behavior/modules/channel_style.js +28 -0
  93. package/server/services/behavior/modules/index.js +29 -0
  94. package/server/services/behavior/modules/norms.js +101 -0
  95. package/server/services/behavior/modules/persona.js +48 -0
  96. package/server/services/behavior/modules/persona_prompt.js +33 -0
  97. package/server/services/behavior/modules/social_memory.js +86 -0
  98. package/server/services/behavior/modules/social_observability.js +94 -0
  99. package/server/services/behavior/modules/social_signals.js +41 -0
  100. package/server/services/behavior/modules/theory_of_mind.js +110 -0
  101. package/server/services/behavior/modules/turn_taking.js +237 -0
  102. package/server/services/behavior/pipeline.js +285 -0
  103. package/server/services/behavior/registry.js +78 -0
  104. package/server/services/behavior/signals.js +107 -0
  105. package/server/services/behavior/state.js +99 -0
  106. package/server/services/behavior/system_prompt.js +75 -0
  107. package/server/services/browser/controller.js +843 -385
  108. package/server/services/browser/extension/gateway.js +40 -16
  109. package/server/services/browser/extension/protocol.js +15 -1
  110. package/server/services/browser/extension/provider.js +71 -47
  111. package/server/services/browser/extension/registry.js +155 -34
  112. package/server/services/cli/executor.js +62 -9
  113. package/server/services/credentials/bitwarden_cli.js +322 -0
  114. package/server/services/credentials/broker.js +594 -0
  115. package/server/services/desktop/gateway.js +41 -4
  116. package/server/services/desktop/protocol.js +3 -0
  117. package/server/services/desktop/provider.js +39 -42
  118. package/server/services/desktop/registry.js +137 -52
  119. package/server/services/integrations/bitwarden/constants.js +14 -0
  120. package/server/services/integrations/bitwarden/provider.js +197 -0
  121. package/server/services/integrations/bitwarden/snapshot.js +65 -0
  122. package/server/services/integrations/figma/provider.js +78 -12
  123. package/server/services/integrations/github/common.js +11 -6
  124. package/server/services/integrations/github/provider.js +52 -53
  125. package/server/services/integrations/google/provider.js +55 -19
  126. package/server/services/integrations/home_assistant/network.js +17 -20
  127. package/server/services/integrations/home_assistant/provider.js +7 -5
  128. package/server/services/integrations/home_assistant/tools.js +17 -5
  129. package/server/services/integrations/http.js +51 -0
  130. package/server/services/integrations/manager.js +159 -53
  131. package/server/services/integrations/microsoft/provider.js +80 -13
  132. package/server/services/integrations/neoarchive/provider.js +55 -29
  133. package/server/services/integrations/neorecall/client.js +17 -10
  134. package/server/services/integrations/neorecall/provider.js +20 -11
  135. package/server/services/integrations/notion/provider.js +16 -13
  136. package/server/services/integrations/oauth_provider.js +115 -51
  137. package/server/services/integrations/registry.js +2 -0
  138. package/server/services/integrations/slack/provider.js +98 -9
  139. package/server/services/integrations/spotify/provider.js +67 -71
  140. package/server/services/integrations/trello/provider.js +21 -7
  141. package/server/services/integrations/weather/provider.js +18 -12
  142. package/server/services/integrations/whatsapp/provider.js +76 -16
  143. package/server/services/manager.js +110 -1
  144. package/server/services/memory/embedding_index.js +20 -8
  145. package/server/services/memory/embeddings.js +151 -90
  146. package/server/services/memory/ingestion.js +50 -9
  147. package/server/services/memory/ingestion_documents.js +13 -3
  148. package/server/services/memory/manager.js +66 -52
  149. package/server/services/messaging/access_policy.js +10 -6
  150. package/server/services/messaging/automation.js +240 -34
  151. package/server/services/messaging/discord.js +11 -1
  152. package/server/services/messaging/formatting_guides.js +5 -4
  153. package/server/services/messaging/http_platforms.js +37 -16
  154. package/server/services/messaging/inbound_queue.js +143 -28
  155. package/server/services/messaging/inbound_store.js +257 -0
  156. package/server/services/messaging/manager.js +344 -51
  157. package/server/services/messaging/telegram.js +10 -1
  158. package/server/services/messaging/typing_keepalive.js +5 -2
  159. package/server/services/messaging/whatsapp.js +33 -14
  160. package/server/services/network/http.js +210 -0
  161. package/server/services/network/safe_request.js +307 -0
  162. package/server/services/runtime/backends/local-vm.js +227 -67
  163. package/server/services/runtime/docker-vm-manager.js +9 -0
  164. package/server/services/runtime/guest_bootstrap.js +30 -4
  165. package/server/services/runtime/guest_image.js +43 -12
  166. package/server/services/runtime/manager.js +77 -23
  167. package/server/services/runtime/validation.js +7 -6
  168. package/server/services/security/tool_categories.js +6 -0
  169. package/server/services/social_reach/channels/github.js +10 -4
  170. package/server/services/social_reach/channels/reddit.js +4 -4
  171. package/server/services/social_reach/channels/rss.js +2 -2
  172. package/server/services/social_reach/channels/social_video.js +13 -8
  173. package/server/services/social_reach/channels/v2ex.js +21 -8
  174. package/server/services/social_reach/channels/x.js +2 -2
  175. package/server/services/social_reach/channels/xueqiu.js +5 -5
  176. package/server/services/social_reach/service.js +9 -6
  177. package/server/services/social_reach/utils.js +65 -14
  178. package/server/services/social_video/captions.js +2 -2
  179. package/server/services/social_video/service.js +343 -68
  180. package/server/services/tasks/integration_runtime.js +18 -8
  181. package/server/services/tasks/runtime.js +39 -4
  182. package/server/services/voice/agentBridge.js +17 -4
  183. package/server/services/voice/bufferedLiveRelayAdapter.js +5 -0
  184. package/server/services/voice/liveSession.js +31 -0
  185. package/server/services/voice/message.js +1 -1
  186. package/server/services/voice/openaiSpeech.js +33 -8
  187. package/server/services/voice/providers.js +233 -151
  188. package/server/services/voice/runtime.js +2 -2
  189. package/server/services/voice/runtimeManager.js +118 -20
  190. package/server/services/voice/turnRunner.js +6 -0
  191. package/server/services/wearable/firmware_manifest.js +51 -13
  192. package/server/services/wearable/service.js +1 -0
  193. package/server/utils/abort.js +96 -0
  194. package/server/utils/cloud-security.js +110 -3
  195. package/server/utils/files.js +31 -0
  196. package/server/utils/image_payload.js +95 -0
  197. package/server/utils/logger.js +19 -0
  198. package/server/utils/retry.js +107 -0
@@ -55,6 +55,16 @@ const { normalizeCompletionConfidence, shouldAcceptTaskComplete } = require('../
55
55
  const { enforceRateLimits } = require('../rate_limits');
56
56
  const { ToolRepetitionGuard } = require('../repetitionGuard');
57
57
  const { shortenRunId, summarizeForLog } = require('../logFormat');
58
+ const {
59
+ normalizeModelSelections,
60
+ resolveModelSelection,
61
+ } = require('../model_identity');
62
+ const {
63
+ isModelCoolingDown,
64
+ recordModelFailure,
65
+ recordModelSuccess,
66
+ } = require('../model_failure_cache');
67
+ const { getProviderForUser } = require('../provider_selector');
58
68
  const { IterationBudget } = require('./iteration_budget');
59
69
  const {
60
70
  buildBlankAfterToolFailureGuidance,
@@ -116,7 +126,7 @@ const {
116
126
  const {
117
127
  requestModelResponse: requestModelResponseImpl,
118
128
  requestStructuredJson: requestStructuredJsonImpl,
119
- withModelCallTimeout,
129
+ runAbortableModelCall,
120
130
  } = require('./model_io');
121
131
  const {
122
132
  publishInterimUpdate: publishInterimUpdateImpl,
@@ -144,6 +154,7 @@ const {
144
154
  buildModelFailureLoopPrompt,
145
155
  } = require('../messagingFallback');
146
156
  const {
157
+ assessResearchAdequacy,
147
158
  classifyToolExecution,
148
159
  gatheredNewEvidence,
149
160
  isSubstantiveProgressToolName,
@@ -164,6 +175,7 @@ const {
164
175
  normalizeRetrievalPlan,
165
176
  shouldEnhanceRetrieval,
166
177
  } = require('../../memory/retrieval_reasoning');
178
+ const { isAbortError, throwIfAborted } = require('../../../utils/abort');
167
179
 
168
180
  function generateTitle(task) {
169
181
  if (!task || typeof task !== 'string') return 'Untitled';
@@ -291,13 +303,34 @@ function summarizeReadTargets(toolExecutions = []) {
291
303
  return targets.join('; ');
292
304
  }
293
305
 
294
- function buildNoProgressWrapupPrompt({ readOnlyCount = 0, alreadyRead = '', platform = null } = {}) {
306
+ function buildNoProgressWrapupPrompt({
307
+ readOnlyCount = 0,
308
+ alreadyRead = '',
309
+ platform = null,
310
+ researchAdequacy = null,
311
+ } = {}) {
312
+ const adequacy = researchAdequacy && typeof researchAdequacy === 'object'
313
+ ? researchAdequacy
314
+ : null;
315
+ const researchIncomplete = adequacy
316
+ && adequacy.adequate === false
317
+ && adequacy.intensity
318
+ && adequacy.intensity !== 'none';
295
319
  return [
296
320
  `This is the final turn for this run (no further tool calls; ${Math.max(0, Number(readOnlyCount) || 0)} read-only turns without a state change).`,
297
321
  alreadyRead ? `Already gathered: ${alreadyRead}.` : '',
322
+ researchIncomplete
323
+ ? `Research coverage is incomplete: ${adequacy.reason || 'requested targets still lack primary evidence.'}`
324
+ : '',
325
+ researchIncomplete && adequacy.uncoveredTargets?.length
326
+ ? `Uncovered research targets: ${adequacy.uncoveredTargets.join('; ')}.`
327
+ : '',
298
328
  'Write the answer now from everything you have already gathered in this conversation. Deliver the useful result you DO have — calendar, weather, emails, search findings, whatever was collected — formatted as the actual answer to the original request.',
299
329
  'If one part could not be retrieved, still deliver everything else and note the missing part in at most one short clause. Never withhold a useful answer because a single detail is missing.',
300
- 'Only report a pure blocker if you genuinely gathered nothing usable at all. Do not describe the result as unfinished, unconfirmed, "blocked", or "still working" when you have something useful this IS the final answer.',
330
+ 'Do not invent entities, products, people, files, outcomes, or next-step work that is not already supported by tool evidence in this conversation.',
331
+ researchIncomplete
332
+ ? 'Because research is incomplete, clearly label assumptions and missing targets. Prefer a partial verified answer or a concrete blocker over a confident fabricated comparison.'
333
+ : 'Only report a pure blocker if you genuinely gathered nothing usable at all. Do not describe the result as unfinished, unconfirmed, "blocked", or "still working" when you have something useful — this IS the final answer.',
301
334
  buildMaxIterationWrapupPrompt(platform),
302
335
  ].filter(Boolean).join('\n\n');
303
336
  }
@@ -403,145 +436,75 @@ function buildAutonomyPolicyFromAnalysis(analysis = {}) {
403
436
  };
404
437
  }
405
438
 
406
- async function getProviderForUser(userId, task = '', isSubagent = false, modelOverride = null, providerConfig = {}) {
407
- const { getSupportedModels, createProviderInstance } = require('../models');
408
- const agentId = providerConfig.agentId || null;
409
- const aiSettings = getAiSettings(userId, agentId);
410
- const models = await getSupportedModels(userId, agentId);
411
-
412
- let enabledIds = Array.isArray(aiSettings.enabled_models) ? aiSettings.enabled_models : [];
413
- const defaultChatModel = aiSettings.default_chat_model || 'auto';
414
- const defaultSubagentModel = aiSettings.default_subagent_model || 'auto';
415
- const smarterSelection = aiSettings.smarter_model_selector !== false && aiSettings.smarter_model_selector !== 'false';
416
-
417
- const knownModelIds = new Set(models.map((m) => m.id));
418
- const selectableModels = models.filter((m) => m.available !== false);
419
-
420
- enabledIds = Array.isArray(enabledIds)
421
- ? enabledIds
422
- .map((id) => String(id))
423
- .filter((id) => knownModelIds.has(id))
424
- : [];
425
-
426
- let availableModels = selectableModels.filter((m) => enabledIds.includes(m.id));
427
- if (availableModels.length === 0) {
428
- enabledIds = selectableModels.map((m) => m.id);
429
- availableModels = [...selectableModels];
430
- }
431
-
432
- const fallbackModel = availableModels.length > 0 ? availableModels[0] : selectableModels[0];
433
-
434
- if (!fallbackModel) {
435
- throw new Error('No AI providers are currently available. Open Settings and configure at least one provider.');
436
- }
437
-
438
- let selectedModelDef = fallbackModel;
439
- const userSelectedDefault = isSubagent ? defaultSubagentModel : defaultChatModel;
440
-
441
- if (modelOverride && typeof modelOverride === 'string') {
442
- const requested = models.find((m) => m.id === modelOverride.trim());
443
- // An explicit override (e.g. a failure fallback picked by getFailureFallbackModelId)
444
- // may intentionally sit outside the user's enabled_models pool — that's the whole
445
- // point when no in-pool cross-provider option exists. Only require availability.
446
- if (requested && requested.available !== false) {
447
- selectedModelDef = requested;
448
- return {
449
- provider: createProviderInstance(selectedModelDef.provider, userId, providerConfig),
450
- model: selectedModelDef.id,
451
- providerName: selectedModelDef.provider
452
- };
453
- }
454
- }
455
-
456
- if (userSelectedDefault && userSelectedDefault !== 'auto') {
457
- selectedModelDef = models.find((m) => m.id === userSelectedDefault) || fallbackModel;
458
- } else {
459
- const selectionHint = providerConfig.selectionHint && typeof providerConfig.selectionHint === 'object'
460
- ? providerConfig.selectionHint
461
- : {};
462
- const preferredPurpose = String(selectionHint.purpose || '').trim().toLowerCase();
463
- const highAutonomy = selectionHint.autonomyLevel === 'high' || selectionHint.complexity === 'complex';
464
- const requiredConfidence = String(selectionHint.requiredConfidence || '').trim().toLowerCase();
465
- const costMode = String(selectionHint.costMode || aiSettings.cost_mode || 'balanced_auto').trim().toLowerCase();
466
- const requestedPurpose = ['planning', 'coding', 'general', 'fast'].includes(preferredPurpose)
467
- ? preferredPurpose
468
- : '';
469
- const priceRank = { free: 0, cheap: 1, medium: 2, expensive: 3 };
470
- const chooseForPurpose = (purpose) => {
471
- const candidates = availableModels.filter((model) => model.purpose === purpose);
472
- if (candidates.length === 0) return null;
473
- if (['economy', 'cost_saver', 'lowest_cost'].includes(costMode)) {
474
- return [...candidates].sort((left, right) => (
475
- (priceRank[left.priceTier] ?? 99) - (priceRank[right.priceTier] ?? 99)
476
- ))[0];
477
- }
478
- if (['quality', 'highest_quality'].includes(costMode) || requiredConfidence === 'high') {
479
- return candidates.find((model) => model.priceTier !== 'free' && model.priceTier !== 'cheap') || candidates[0];
480
- }
481
- return candidates[0];
482
- };
483
-
484
- if (smarterSelection && requestedPurpose) {
485
- selectedModelDef = chooseForPurpose(requestedPurpose) || fallbackModel;
486
- } else if (smarterSelection && highAutonomy) {
487
- selectedModelDef = chooseForPurpose('planning') || chooseForPurpose('general') || fallbackModel;
488
- } else if (isSubagent) {
489
- selectedModelDef = chooseForPurpose('fast') || fallbackModel;
490
- } else {
491
- selectedModelDef = chooseForPurpose('general') || fallbackModel;
492
- }
493
- }
494
-
495
- return {
496
- provider: createProviderInstance(selectedModelDef.provider, userId, providerConfig),
497
- model: selectedModelDef.id,
498
- providerName: selectedModelDef.provider
499
- };
500
- }
501
-
502
- async function getFailureFallbackModelId(userId, agentId, currentModelId, preferredFallbackId = null, failureError = null) {
439
+ async function getFailureFallbackModelId(
440
+ userId,
441
+ agentId,
442
+ currentModelId,
443
+ preferredFallbackId = null,
444
+ failureError = null,
445
+ signal = null,
446
+ excludedModelIds = [],
447
+ ) {
503
448
  const { getSupportedModels } = require('../models');
504
449
  const aiSettings = getAiSettings(userId, agentId);
505
- const models = await getSupportedModels(userId, agentId);
506
- const availableModels = models.filter((model) => model.available !== false);
507
- const knownIds = new Set(availableModels.map((model) => model.id));
508
- const enabledIds = Array.isArray(aiSettings.enabled_models)
509
- ? aiSettings.enabled_models.map((id) => String(id)).filter((id) => knownIds.has(id))
450
+ const models = await getSupportedModels(userId, agentId, { signal });
451
+ const excluded = new Set(
452
+ [...excludedModelIds]
453
+ .map((id) => String(id || '').trim())
454
+ .filter(Boolean),
455
+ );
456
+ const availableModels = models.filter(
457
+ (model) => model.available !== false
458
+ && !isModelCoolingDown(userId, agentId, model.id)
459
+ && !excluded.has(model.id),
460
+ );
461
+ const configuredEnabledIds = Array.isArray(aiSettings.enabled_models)
462
+ ? aiSettings.enabled_models.map((id) => String(id).trim()).filter(Boolean)
510
463
  : [];
511
- const pool = enabledIds.length > 0
464
+ const enabledIds = normalizeModelSelections(availableModels, configuredEnabledIds);
465
+ const pool = configuredEnabledIds.length > 0
512
466
  ? availableModels.filter((model) => enabledIds.includes(model.id))
513
467
  : availableModels;
514
- const fallbackSearchPool = pool.length > 0 ? pool : availableModels;
515
- const currentModel = pool.find((model) => model.id === currentModelId)
516
- || availableModels.find((model) => model.id === currentModelId)
517
- || null;
518
-
519
- // When the failure is a provider-level rate limit, the preferred fallback is
520
- // likely on the same provider and will hit the same limit. Skip it and prefer
521
- // a fallback from a different provider instead.
522
- const isProviderRateLimit = /429|rate.?limit|free-models-per/i.test(String(failureError?.message || ''));
523
-
524
- if (preferredFallbackId && preferredFallbackId !== currentModelId && !isProviderRateLimit) {
525
- const preferred = fallbackSearchPool.find((model) => model.id === preferredFallbackId)
526
- || availableModels.find((model) => model.id === preferredFallbackId);
527
- if (preferred) return preferred.id;
468
+ const fallbackSearchPool = pool;
469
+ const currentModel = resolveModelSelection(models, currentModelId);
470
+
471
+ // Provider-wide failures (credentials, throttling, or service outages) are
472
+ // likely to affect another model on the same provider. Prefer a different
473
+ // provider before consulting the configured fallback.
474
+ const failureStatus = Number(
475
+ failureError?.status
476
+ ?? failureError?.statusCode
477
+ ?? failureError?.response?.status,
478
+ );
479
+ const isProviderScopedFailure = (
480
+ failureStatus === 401
481
+ || failureStatus === 403
482
+ || failureStatus === 429
483
+ || (failureStatus >= 500 && failureStatus < 600)
484
+ || /rate.?limit|free-models-per|service unavailable|provider unavailable|authentication|api key/i
485
+ .test(String(failureError?.message || ''))
486
+ );
487
+
488
+ if (preferredFallbackId && !isProviderScopedFailure) {
489
+ const preferred = resolveModelSelection(fallbackSearchPool, preferredFallbackId)
490
+ || resolveModelSelection(availableModels, preferredFallbackId);
491
+ if (preferred && preferred.id !== currentModel?.id) return preferred.id;
528
492
  }
529
493
 
530
494
  if (currentModel?.provider) {
531
495
  const differentProvider = fallbackSearchPool.find((model) =>
532
- model.id !== currentModelId && model.provider !== currentModel.provider);
496
+ model.id !== currentModel.id && model.provider !== currentModel.provider);
533
497
  if (differentProvider) return differentProvider.id;
534
498
  }
535
499
 
536
- // If no different-provider model exists, still try the preferred fallback
537
- // even on rate limits (it's better than nothing).
538
- if (preferredFallbackId && preferredFallbackId !== currentModelId) {
539
- const preferred = fallbackSearchPool.find((model) => model.id === preferredFallbackId)
540
- || availableModels.find((model) => model.id === preferredFallbackId);
541
- if (preferred) return preferred.id;
500
+ // If no different-provider model exists, still try the preferred fallback.
501
+ if (preferredFallbackId) {
502
+ const preferred = resolveModelSelection(fallbackSearchPool, preferredFallbackId)
503
+ || resolveModelSelection(availableModels, preferredFallbackId);
504
+ if (preferred && preferred.id !== currentModel?.id) return preferred.id;
542
505
  }
543
506
 
544
- const differentModel = fallbackSearchPool.find((model) => model.id !== currentModelId);
507
+ const differentModel = fallbackSearchPool.find((model) => model.id !== currentModel?.id);
545
508
  return differentModel?.id || null;
546
509
  }
547
510
 
@@ -552,6 +515,7 @@ function estimateTokenValue(value) {
552
515
  }
553
516
 
554
517
  async function runConversation(engine, userId, userMessage, options = {}, _modelOverride = null) {
518
+ throwIfAborted(options.signal, 'Agent run aborted before startup.');
555
519
  const triggerType = options.triggerType || 'user';
556
520
  const { resolveAgentId } = require('../../agents/manager');
557
521
  const agentId = resolveAgentId(userId, options.agentId || options.agent_id || null);
@@ -563,7 +527,9 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
563
527
  const triggerSource = options.triggerSource || 'web';
564
528
  let provider = null;
565
529
  let model = null;
530
+ let modelSelectionId = null;
566
531
  let providerName = null;
532
+ const modelTurnFailedModelSelectionIds = new Set();
567
533
  let messages = [];
568
534
  let iteration = 0;
569
535
  let totalTokens = 0;
@@ -572,7 +538,9 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
572
538
  let failedStepCount = 0;
573
539
  let toolExecutions = [];
574
540
  let deliverableWorkflow = null;
575
- let runTitle;
541
+ let detachExternalAbort = null;
542
+ const runTitle = generateTitle(userMessage);
543
+ let runRecordCreated = false;
576
544
  const timelineService = app?.locals?.timelineService || null;
577
545
 
578
546
  const { releaseReservation } = enforceRateLimits(userId, {
@@ -589,6 +557,59 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
589
557
  // analysis when the mode is known. See the post-analysis policy rebuild below.
590
558
  let loopPolicy = buildLoopPolicy(aiSettings, triggerType, 'execute', options);
591
559
  let maxIterations = loopPolicy.maxIterations;
560
+ const initialRunMetadata = buildInitialRunMetadata(options);
561
+ const requestedModel = String(
562
+ _modelOverride
563
+ || (triggerType === 'subagent' ? aiSettings.default_subagent_model : aiSettings.default_chat_model)
564
+ || 'auto',
565
+ ).trim();
566
+ try {
567
+ db.prepare(`INSERT INTO agent_runs(
568
+ id, user_id, agent_id, title, status, trigger_type, trigger_source, model, metadata_json
569
+ ) VALUES(?, ?, ?, ?, 'running', ?, ?, ?, ?)`).run(
570
+ runId,
571
+ userId,
572
+ agentId,
573
+ runTitle,
574
+ triggerType,
575
+ triggerSource,
576
+ requestedModel,
577
+ Object.keys(initialRunMetadata).length ? JSON.stringify(initialRunMetadata) : null,
578
+ );
579
+ runRecordCreated = true;
580
+ } catch (error) {
581
+ if (/unique|primary key|constraint/i.test(String(error?.message || ''))) {
582
+ const conflict = new Error(`A run with id "${runId}" already exists.`);
583
+ conflict.code = 'RUN_ID_CONFLICT';
584
+ throw conflict;
585
+ }
586
+ throw error;
587
+ }
588
+ const startupAbortController = new AbortController();
589
+ engine.activeRuns.set(runId, {
590
+ userId,
591
+ agentId,
592
+ title: runTitle,
593
+ status: 'running',
594
+ aborted: false,
595
+ abortController: startupAbortController,
596
+ pauseAvailable: false,
597
+ toolPids: new Set(),
598
+ subagentDepth: Math.max(0, Number(options.subagentDepth) || 0),
599
+ });
600
+ if (options.signal) {
601
+ const abortFromExternal = () => {
602
+ engine.interruptRun(
603
+ runId,
604
+ String(options.signal.reason || 'Agent run interrupted by its caller.'),
605
+ );
606
+ };
607
+ if (options.signal.aborted) abortFromExternal();
608
+ else options.signal.addEventListener('abort', abortFromExternal, { once: true });
609
+ detachExternalAbort = () => {
610
+ options.signal.removeEventListener('abort', abortFromExternal);
611
+ };
612
+ }
592
613
  const providerStatusConfig = {
593
614
  agentId,
594
615
  onStatus: (status) => {
@@ -605,18 +626,61 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
605
626
  userMessage,
606
627
  triggerType === 'subagent',
607
628
  _modelOverride,
608
- providerStatusConfig
629
+ { ...providerStatusConfig, signal: startupAbortController.signal }
609
630
  );
610
631
  provider = selectedProvider.provider;
611
632
  model = selectedProvider.model;
633
+ modelSelectionId = selectedProvider.modelSelectionId;
612
634
  providerName = selectedProvider.providerName;
613
- const switchToFallbackModel = async (failedModel, error, phase) => {
614
- const fallbackModelId = await getFailureFallbackModelId(userId, agentId, failedModel, aiSettings.fallback_model_id, error);
615
- if (!fallbackModelId || fallbackModelId === failedModel) return false;
616
- console.log(`[Engine] ${phase} failed on ${failedModel}; attempting fallback to: ${fallbackModelId}`);
635
+ if (
636
+ startupAbortController.signal.aborted
637
+ || engine.getRunMeta(runId)?.status !== 'running'
638
+ ) {
639
+ const startupError = new Error(
640
+ String(startupAbortController.signal.reason || 'Run stopped during model selection.'),
641
+ );
642
+ startupError.name = 'AbortError';
643
+ startupError.code = 'ABORT_ERR';
644
+ throw startupError;
645
+ }
646
+ db.prepare('UPDATE agent_runs SET model = ?, updated_at = datetime(\'now\') WHERE id = ?')
647
+ .run(modelSelectionId, runId);
648
+ const recordFailedModel = (failedSelectionId, error, excludedModels) => {
649
+ excludedModels.add(failedSelectionId);
650
+ recordModelFailure(userId, agentId, failedSelectionId, error);
651
+ };
652
+ const switchToFallbackModel = async (
653
+ failedSelectionId,
654
+ error,
655
+ phase,
656
+ excludedModels,
657
+ ) => {
658
+ recordFailedModel(failedSelectionId, error, excludedModels);
659
+ const fallbackModelId = await getFailureFallbackModelId(
660
+ userId,
661
+ agentId,
662
+ failedSelectionId,
663
+ aiSettings.fallback_model_id,
664
+ error,
665
+ engine.getRunMeta(runId)?.abortController?.signal,
666
+ excludedModels,
667
+ );
668
+ if (!fallbackModelId || fallbackModelId === failedSelectionId) return false;
669
+ const failureSummary = summarizeForLog(error?.message || error, 180);
670
+ console.log(
671
+ `[Engine] ${phase} failed on ${failedSelectionId}: ${failureSummary}; attempting fallback to: ${fallbackModelId}`
672
+ );
673
+ engine.recordRunEvent(userId, runId, 'model_fallback', {
674
+ phase,
675
+ failedModel: failedSelectionId,
676
+ fallbackModel: fallbackModelId,
677
+ errorCode: error?.code || null,
678
+ errorStatus: error?.status || error?.statusCode || error?.response?.status || null,
679
+ error: failureSummary,
680
+ }, { agentId });
617
681
  engine.emit(userId, 'run:interim', {
618
682
  runId,
619
- message: `Model service failed on ${failedModel}; retrying with ${fallbackModelId}.`,
683
+ message: `Model service failed on ${failedSelectionId}; retrying with ${fallbackModelId}.`,
620
684
  phase: 'model_fallback'
621
685
  });
622
686
  const fallback = await getProviderForUser(
@@ -624,46 +688,50 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
624
688
  userMessage,
625
689
  triggerType === 'subagent',
626
690
  fallbackModelId,
627
- providerStatusConfig
691
+ {
692
+ ...providerStatusConfig,
693
+ signal: engine.getRunMeta(runId)?.abortController?.signal,
694
+ }
628
695
  );
629
696
  provider = fallback.provider;
630
697
  model = fallback.model;
698
+ modelSelectionId = fallback.modelSelectionId;
631
699
  providerName = fallback.providerName;
700
+ db.prepare('UPDATE agent_runs SET model = ?, updated_at = datetime(\'now\') WHERE id = ?')
701
+ .run(modelSelectionId, runId);
702
+ Object.assign(engine.getRunMeta(runId) || {}, {
703
+ model,
704
+ modelSelectionId,
705
+ providerName,
706
+ });
632
707
  return true;
633
708
  };
634
709
  const runWithModelFallback = async (phase, fn) => {
635
- try {
636
- return await fn();
637
- } catch (err) {
638
- const failedModel = model;
639
- const switched = await switchToFallbackModel(failedModel, err, phase);
640
- if (!switched) throw err;
641
- return await fn();
710
+ let recoveries = 0;
711
+ const failedModels = new Set();
712
+ while (true) {
713
+ try {
714
+ const result = await fn();
715
+ recordModelSuccess(userId, agentId, modelSelectionId);
716
+ return result;
717
+ } catch (err) {
718
+ if (recoveries >= loopPolicy.maxModelFailureRecoveries) {
719
+ recordFailedModel(modelSelectionId, err, failedModels);
720
+ throw err;
721
+ }
722
+ const failedSelectionId = modelSelectionId;
723
+ const switched = await switchToFallbackModel(
724
+ failedSelectionId,
725
+ err,
726
+ phase,
727
+ failedModels,
728
+ );
729
+ if (!switched) throw err;
730
+ recoveries += 1;
731
+ }
642
732
  }
643
733
  };
644
734
 
645
- runTitle = generateTitle(userMessage);
646
- const initialRunMetadata = buildInitialRunMetadata(options);
647
- db.prepare(`INSERT INTO agent_runs(
648
- id, user_id, agent_id, title, status, trigger_type, trigger_source, model, metadata_json
649
- ) VALUES(?, ?, ?, ?, 'running', ?, ?, ?, ?)
650
- ON CONFLICT(id) DO UPDATE SET
651
- status = 'running',
652
- model = excluded.model,
653
- updated_at = datetime('now'),
654
- completed_at = NULL,
655
- error = NULL,
656
- metadata_json = COALESCE(agent_runs.metadata_json, excluded.metadata_json)`).run(
657
- runId,
658
- userId,
659
- agentId,
660
- runTitle,
661
- triggerType,
662
- triggerSource,
663
- model,
664
- Object.keys(initialRunMetadata).length ? JSON.stringify(initialRunMetadata) : null,
665
- );
666
-
667
735
  const retryMessagingState = options.messagingRetryState || {};
668
736
  const carriedFinalMessage = String(retryMessagingState.lastFinalMessage || '').trim();
669
737
  const carriedExplicitMessageSent = retryMessagingState.explicitMessageSent === true;
@@ -685,6 +753,9 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
685
753
  userId,
686
754
  agentId,
687
755
  title: runTitle,
756
+ model,
757
+ modelSelectionId,
758
+ providerName,
688
759
  status: 'running',
689
760
  aborted: false,
690
761
  messagingSent: false,
@@ -709,7 +780,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
709
780
  voiceSessionId: options.voiceSessionId || null,
710
781
  steeringQueue: [],
711
782
  systemSteeringQueue: [],
712
- abortController: new AbortController(),
783
+ abortController: startupAbortController,
713
784
  pauseAvailable: false,
714
785
  toolPids: new Set(),
715
786
  subagentDepth: Math.max(0, Number(options.subagentDepth) || 0),
@@ -720,6 +791,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
720
791
  ? {
721
792
  platform: options.source || null,
722
793
  chatId: options.chatId || null,
794
+ behavior: options.context?.socialIntelligence || null,
723
795
  }
724
796
  : null,
725
797
  goalContract: carriedGoalContract,
@@ -734,11 +806,24 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
734
806
  progressLedger,
735
807
  goalContract: carriedGoalContract,
736
808
  });
737
- engine.startMessagingProgressSupervisor(runId);
738
- engine.emit(userId, 'run:start', { runId, agentId, title: runTitle, model, triggerType, triggerSource });
809
+ if (options.context?.socialIntelligence?.isGroup !== true) {
810
+ engine.startMessagingProgressSupervisor(runId);
811
+ }
812
+ engine.emit(userId, 'run:start', {
813
+ runId,
814
+ agentId,
815
+ title: runTitle,
816
+ model,
817
+ modelSelectionId,
818
+ provider: providerName,
819
+ triggerType,
820
+ triggerSource,
821
+ });
739
822
  engine.recordRunEvent(userId, runId, 'run_started', {
740
823
  title: runTitle,
741
824
  model,
825
+ modelSelectionId,
826
+ provider: providerName,
742
827
  triggerType,
743
828
  triggerSource,
744
829
  }, { agentId });
@@ -752,7 +837,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
752
837
  triggerSource,
753
838
  });
754
839
  console.info(
755
- `[Run ${shortenRunId(runId)}] started trigger=${triggerSource} type=${triggerType} model=${model} title=${summarizeForLog(runTitle, 120)}`
840
+ `[Run ${shortenRunId(runId)}] started trigger=${triggerSource} type=${triggerType} model=${modelSelectionId} title=${summarizeForLog(runTitle, 120)}`
756
841
  );
757
842
 
758
843
  const systemPrompt = await engine.buildSystemPrompt(userId, {
@@ -760,6 +845,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
760
845
  userMessage,
761
846
  agentId,
762
847
  triggerSource,
848
+ memoryAudience: options.memoryAudience || 'owner',
763
849
  });
764
850
  // Pass short descriptions so the model always knows every available tool.
765
851
  // compactToolDefinition caps tool desc at 120 chars, param desc at 70 chars.
@@ -883,7 +969,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
883
969
  if (triggerSource === 'messaging') {
884
970
  const runMetaForCompose = engine.getRunMeta(runId);
885
971
  if (runMetaForCompose) {
886
- runMetaForCompose.composeProgressUpdate = async ({ stalled = false } = {}) => {
972
+ runMetaForCompose.composeProgressUpdate = async ({ stalled = false, signal = null } = {}) => {
887
973
  try {
888
974
  const rm = engine.getRunMeta(runId);
889
975
  const ledger = rm?.progressLedger || {};
@@ -900,7 +986,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
900
986
  const priorUpdate = String(rm?.lastInterimMessage || '').trim();
901
987
  const contextBlock = [
902
988
  buildProgressUpdatePrompt(),
903
- stalled ? 'This step has been running a while with no new progress; reassure the user you are still on it.' : '',
989
+ stalled ? 'No verified progress has occurred for the stall threshold. State that fact plainly if it matters; do not reassure, promise continued work, or imply activity beyond the evidence.' : '',
904
990
  '',
905
991
  `Original request: ${summarizeForLog(userMessage, 320)}`,
906
992
  currentTool ? `Doing now: using ${currentTool}` : '',
@@ -910,17 +996,24 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
910
996
  // Reuse the run's real system prompt so the update follows the same voice and
911
997
  // formatting guidelines as every other message (single source of truth).
912
998
  const sysContent = [systemPrompt?.stable, systemPrompt?.dynamic].filter(Boolean).join('\n\n')
913
- || 'You are a helpful assistant.';
914
- const resp = await withModelCallTimeout(
915
- provider.chat(
999
+ || 'Respond directly and accurately using only the verified progress evidence.';
1000
+ const resp = await runAbortableModelCall(
1001
+ (signal) => provider.chat(
916
1002
  [
917
1003
  { role: 'system', content: sysContent },
918
1004
  { role: 'user', content: contextBlock },
919
1005
  ],
920
1006
  [],
921
- { model, reasoningEffort: engine.getReasoningEffort(providerName, options) },
1007
+ {
1008
+ model,
1009
+ reasoningEffort: engine.getReasoningEffort(providerName, options),
1010
+ signal,
1011
+ },
922
1012
  ),
923
- options,
1013
+ {
1014
+ ...options,
1015
+ signals: [rm?.abortController?.signal, signal],
1016
+ },
924
1017
  'Progress update compose',
925
1018
  );
926
1019
  return sanitizeModelOutput(resp.content || '', { model });
@@ -1031,6 +1124,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1031
1124
  null,
1032
1125
  {
1033
1126
  ...providerStatusConfig,
1127
+ signal: engine.getRunMeta(runId)?.abortController?.signal,
1034
1128
  selectionHint: {
1035
1129
  purpose: requestedPurpose,
1036
1130
  complexity: analysis?.complexity,
@@ -1040,15 +1134,21 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1040
1134
  },
1041
1135
  }
1042
1136
  );
1043
- if (selectedAfterAnalysis.model !== model) {
1137
+ if (selectedAfterAnalysis.modelSelectionId !== modelSelectionId) {
1044
1138
  provider = selectedAfterAnalysis.provider;
1045
1139
  model = selectedAfterAnalysis.model;
1140
+ modelSelectionId = selectedAfterAnalysis.modelSelectionId;
1046
1141
  providerName = selectedAfterAnalysis.providerName;
1047
1142
  db.prepare('UPDATE agent_runs SET model = ?, updated_at = datetime(\'now\') WHERE id = ?')
1048
- .run(model, runId);
1143
+ .run(modelSelectionId, runId);
1144
+ Object.assign(engine.getRunMeta(runId) || {}, {
1145
+ model,
1146
+ modelSelectionId,
1147
+ providerName,
1148
+ });
1049
1149
  engine.emit(userId, 'run:interim', {
1050
1150
  runId,
1051
- message: `Switched to ${model} for this run after task analysis.`,
1151
+ message: `Switched to ${modelSelectionId} for this run after task analysis.`,
1052
1152
  phase: 'model_selection'
1053
1153
  });
1054
1154
  }
@@ -1239,7 +1339,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1239
1339
  reason,
1240
1340
  stoppedBy: hookResult.stopped_by || hookResult.stoppedBy || null,
1241
1341
  }, { agentId });
1242
- lastContent = reason;
1342
+ engine.stopRun(runId, reason);
1243
1343
  break;
1244
1344
  }
1245
1345
  const systemSteering = String(hookResult?.systemSteering || hookResult?.system_steering || '').trim();
@@ -1298,6 +1398,47 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1298
1398
  continue;
1299
1399
  }
1300
1400
  alreadyRead = summarizeReadTargets(toolExecutions);
1401
+ const researchAdequacyAtCap = assessResearchAdequacy({
1402
+ analysis,
1403
+ goalContext: runGoalCtx,
1404
+ toolExecutions,
1405
+ });
1406
+ // Incomplete multi-target research is not "stuck" yet if primary sources
1407
+ // remain uncovered. Give one guided research continuation instead of
1408
+ // force-finishing with a guessed comparison.
1409
+ if (
1410
+ researchAdequacyAtCap.adequate === false
1411
+ && researchAdequacyAtCap.intensity !== 'none'
1412
+ && iterMeta
1413
+ && iterMeta.researchAdequacyDeferralUsed !== true
1414
+ && (
1415
+ researchAdequacyAtCap.uncoveredTargets.length > 0
1416
+ || researchAdequacyAtCap.primarySourceCount < researchAdequacyAtCap.requiredPrimarySources
1417
+ )
1418
+ ) {
1419
+ iterMeta.researchAdequacyDeferralUsed = true;
1420
+ iterMeta.consecutiveReadOnlyIterations = Math.max(0, churnNudgeThreshold - 1);
1421
+ messages.push({
1422
+ role: 'system',
1423
+ content: [
1424
+ 'Research self-check: evidence is still incomplete, so this run must not force-finish yet.',
1425
+ researchAdequacyAtCap.reason ? `Gap: ${researchAdequacyAtCap.reason}` : '',
1426
+ researchAdequacyAtCap.nextActions?.length
1427
+ ? `Next safe steps:\n- ${researchAdequacyAtCap.nextActions.join('\n- ')}`
1428
+ : '',
1429
+ 'Open or fetch primary sources for uncovered targets now. Do not invent the missing comparison from memory or search snippets alone.',
1430
+ ].filter(Boolean).join('\n'),
1431
+ });
1432
+ engine.recordRunEvent(userId, runId, 'read_only_wrapup_deferred_for_research', {
1433
+ iteration,
1434
+ readOnlyCount,
1435
+ intensity: researchAdequacyAtCap.intensity,
1436
+ uncoveredTargets: researchAdequacyAtCap.uncoveredTargets,
1437
+ primarySourceCount: researchAdequacyAtCap.primarySourceCount,
1438
+ requiredPrimarySources: researchAdequacyAtCap.requiredPrimarySources,
1439
+ }, { agentId });
1440
+ continue;
1441
+ }
1301
1442
  triggerForceWrapup = true;
1302
1443
  } else if (readOnlyCount >= churnNudgeThreshold) {
1303
1444
  alreadyRead = summarizeReadTargets(toolExecutions);
@@ -1362,8 +1503,8 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1362
1503
  });
1363
1504
  let wrapTokens = 0;
1364
1505
  try {
1365
- const wrapResponse = await withModelCallTimeout(
1366
- provider.chat(
1506
+ const wrapResponse = await runAbortableModelCall(
1507
+ (signal) => provider.chat(
1367
1508
  sanitizeConversationMessages([
1368
1509
  ...messages,
1369
1510
  {
@@ -1372,13 +1513,22 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1372
1513
  readOnlyCount,
1373
1514
  alreadyRead,
1374
1515
  platform: options?.source || null,
1516
+ researchAdequacy: assessResearchAdequacy({
1517
+ analysis,
1518
+ goalContext: runGoalCtx,
1519
+ toolExecutions,
1520
+ }),
1375
1521
  }),
1376
1522
  },
1377
1523
  ]),
1378
1524
  [],
1379
- { model, reasoningEffort: engine.getReasoningEffort(providerName, options) },
1525
+ {
1526
+ model,
1527
+ reasoningEffort: engine.getReasoningEffort(providerName, options),
1528
+ signal,
1529
+ },
1380
1530
  ),
1381
- options,
1531
+ { ...options, signal: engine.getRunMeta(runId)?.abortController?.signal },
1382
1532
  'No-progress wrap-up',
1383
1533
  );
1384
1534
  wrapTokens = wrapResponse.usage?.totalTokens || 0;
@@ -1431,9 +1581,9 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1431
1581
  let metrics = engine.estimatePromptMetrics(messages, tools);
1432
1582
  const contextWindow = provider.getContextWindow(model);
1433
1583
  if (metrics.totalEstimatedTokens > contextWindow * loopPolicy.compactionThreshold) {
1434
- messages = await withModelCallTimeout(
1435
- compact(messages, provider, model, contextWindow),
1436
- options,
1584
+ messages = await runAbortableModelCall(
1585
+ (signal) => compact(messages, provider, model, contextWindow, { signal }),
1586
+ { ...options, signal: engine.getRunMeta(runId)?.abortController?.signal },
1437
1587
  `Context compaction before iteration ${iteration}`,
1438
1588
  );
1439
1589
  messages = sanitizeConversationMessages(messages);
@@ -1453,48 +1603,59 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1453
1603
  let responseModel = model;
1454
1604
  let streamContent = '';
1455
1605
 
1456
- const tryModelCall = async (retryForFallback = true) => {
1457
- try {
1458
- const modelCall = await engine.requestModelResponse({
1459
- provider,
1460
- providerName,
1461
- model,
1462
- messages,
1463
- tools,
1464
- options: {
1465
- ...options,
1466
- userId,
1467
- agentId,
1606
+ const tryModelCall = async () => {
1607
+ let requestMessages = messages;
1608
+ let requestPhase = 'model_turn';
1609
+ while (true) {
1610
+ try {
1611
+ const modelCall = await engine.requestModelResponse({
1612
+ provider,
1613
+ providerName,
1614
+ model,
1615
+ messages: requestMessages,
1616
+ tools,
1617
+ options: {
1618
+ ...options,
1619
+ userId,
1620
+ agentId,
1621
+ runId,
1622
+ phase: requestPhase,
1623
+ signal: engine.getRunMeta(runId)?.abortController?.signal,
1624
+ },
1468
1625
  runId,
1469
- phase: 'model_turn',
1470
- signal: engine.getRunMeta(runId)?.abortController?.signal,
1471
- },
1472
- runId,
1473
- iteration,
1474
- });
1475
- response = modelCall.response;
1476
- responseModel = modelCall.responseModel;
1477
- streamContent = modelCall.streamContent;
1478
- } catch (err) {
1479
- console.error(`[Engine] Model call failed (${model}):`, err.message);
1480
- const fallbackModelId = retryForFallback
1481
- ? await getFailureFallbackModelId(userId, agentId, model, aiSettings.fallback_model_id, err)
1482
- : null;
1483
- if (fallbackModelId) {
1626
+ iteration,
1627
+ });
1628
+ response = modelCall.response;
1629
+ responseModel = modelCall.responseModel;
1630
+ streamContent = modelCall.streamContent;
1631
+ recordModelSuccess(userId, agentId, modelSelectionId);
1632
+ return;
1633
+ } catch (err) {
1634
+ console.error(`[Engine] Model call failed (${model}):`, err.message);
1635
+ const runSignal = engine.getRunMeta(runId)?.abortController?.signal;
1636
+ if (isAbortError(err) || runSignal?.aborted) throw err;
1637
+ if (modelFailureRecoveries >= loopPolicy.maxModelFailureRecoveries) {
1638
+ recordFailedModel(
1639
+ modelSelectionId,
1640
+ err,
1641
+ modelTurnFailedModelSelectionIds,
1642
+ );
1643
+ throw err;
1644
+ }
1645
+
1484
1646
  const failedModel = model;
1485
- console.log(`[Engine] Attempting fallback to: ${fallbackModelId}`);
1486
- const fallback = await getProviderForUser(
1487
- userId,
1488
- userMessage,
1489
- triggerType === 'subagent',
1490
- fallbackModelId,
1491
- providerStatusConfig
1647
+ const switched = await switchToFallbackModel(
1648
+ modelSelectionId,
1649
+ err,
1650
+ 'model turn',
1651
+ modelTurnFailedModelSelectionIds,
1492
1652
  );
1493
- provider = fallback.provider;
1494
- model = fallback.model;
1495
- providerName = fallback.providerName;
1653
+ if (!switched) throw err;
1496
1654
 
1497
- const retryMessages = sanitizeConversationMessages([
1655
+ modelFailureRecoveries += 1;
1656
+ failedStepCount += 1;
1657
+ requestPhase = 'model_turn_fallback';
1658
+ requestMessages = sanitizeConversationMessages([
1498
1659
  ...messages,
1499
1660
  {
1500
1661
  role: 'system',
@@ -1505,22 +1666,6 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1505
1666
  })
1506
1667
  }
1507
1668
  ]);
1508
-
1509
- const fallbackCall = await engine.requestModelResponse({
1510
- provider,
1511
- providerName,
1512
- model,
1513
- messages: retryMessages,
1514
- tools,
1515
- options: { ...options, userId },
1516
- runId,
1517
- iteration,
1518
- });
1519
- response = fallbackCall.response;
1520
- responseModel = fallbackCall.responseModel;
1521
- streamContent = fallbackCall.streamContent;
1522
- } else {
1523
- throw err;
1524
1669
  }
1525
1670
  }
1526
1671
  };
@@ -1543,30 +1688,6 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1543
1688
  continue;
1544
1689
  }
1545
1690
  if (lifecycleControl?.action === 'stop' || lifecycleControl?.action === 'interrupt') break;
1546
- const modelError = String(err?.message || 'Model call failed');
1547
-
1548
- if (modelFailureRecoveries < loopPolicy.maxModelFailureRecoveries) {
1549
- const failedModel = model;
1550
- const switched = await switchToFallbackModel(failedModel, err, 'model turn');
1551
- if (!switched) throw err;
1552
- modelFailureRecoveries += 1;
1553
- failedStepCount += 1;
1554
- messages.push({
1555
- role: 'system',
1556
- content: buildModelFailureLoopPrompt({
1557
- failedModel,
1558
- nextModel: model,
1559
- errorMessage: modelError
1560
- })
1561
- });
1562
- engine.emit(userId, 'run:interim', {
1563
- runId,
1564
- message: 'Model call failed; adapting and retrying autonomously.',
1565
- phase: 'recovering'
1566
- });
1567
- continue;
1568
- }
1569
-
1570
1691
  throw err;
1571
1692
  }
1572
1693
 
@@ -1601,10 +1722,6 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1601
1722
  toolCallCount: response.toolCalls?.length || 0,
1602
1723
  contentPreview: String(lastContent || streamContent || '').slice(0, 240),
1603
1724
  }, { agentId });
1604
- engine.updateRunProgress(runId, {}, {
1605
- verified: true,
1606
- });
1607
-
1608
1725
  const assistantMessage = { role: 'assistant', content: lastContent };
1609
1726
  if (response.toolCalls?.length) assistantMessage.tool_calls = response.toolCalls;
1610
1727
  if (response.providerContentBlocks?.length) assistantMessage.providerContentBlocks = response.providerContentBlocks;
@@ -1650,15 +1767,6 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1650
1767
  lastContent = '';
1651
1768
  continue;
1652
1769
  }
1653
- if (engine.shouldFastCompleteVoiceReply({
1654
- options,
1655
- toolExecutions,
1656
- failedStepCount,
1657
- messagingSent: engine.activeRuns.get(runId)?.messagingSent || false,
1658
- lastReply: lastContent,
1659
- })) {
1660
- break;
1661
- }
1662
1770
  if (shouldContinueAfterBlankToolFailure({
1663
1771
  lastContent,
1664
1772
  failedStepCount,
@@ -1714,13 +1822,23 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1714
1822
  iteration,
1715
1823
  }, { agentId });
1716
1824
  if (loopDecision.decision.status === 'continue') {
1825
+ const researchGuidance = loopDecision.researchAdequacy?.adequate === false
1826
+ ? [
1827
+ `Research still incomplete: ${loopDecision.researchAdequacy.reason || 'gather primary evidence for remaining targets.'}`,
1828
+ loopDecision.researchAdequacy.nextActions?.length
1829
+ ? `Next safe steps: ${loopDecision.researchAdequacy.nextActions.join(' ')}`
1830
+ : '',
1831
+ ].filter(Boolean).join(' ')
1832
+ : '';
1717
1833
  messages.push({
1718
1834
  role: 'system',
1719
1835
  content: [
1720
1836
  'The run self-check determined the latest assistant text is not terminal.',
1721
1837
  'Continue with the next safe tool/model step.',
1722
1838
  'If the text was only a progress note, do not repeat it; either make progress or provide a real final/blocker reply.',
1723
- ].join(' '),
1839
+ 'Do not invent missing targets, outcomes, or tool results. Gather evidence or return a truthful partial/blocker answer.',
1840
+ researchGuidance,
1841
+ ].filter(Boolean).join(' '),
1724
1842
  });
1725
1843
  lastContent = '';
1726
1844
  continue;
@@ -1731,7 +1849,10 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1731
1849
 
1732
1850
  const canRunParallelBatch = (
1733
1851
  response.toolCalls.length > 1
1734
- && response.toolCalls.every((toolCall) => engine.isReadOnlyToolCall(toolCall))
1852
+ && response.toolCalls.every((toolCall) => engine.isReadOnlyToolCall(
1853
+ toolCall,
1854
+ tools.find((tool) => tool?.name === toolCall?.function?.name) || null,
1855
+ ))
1735
1856
  );
1736
1857
  if (canRunParallelBatch) {
1737
1858
  const parallelToolNames = response.toolCalls
@@ -1768,6 +1889,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1768
1889
  item.toolArgs,
1769
1890
  item.result,
1770
1891
  item.error || '',
1892
+ tools.find((tool) => tool?.name === item.toolName) || null,
1771
1893
  );
1772
1894
  execution.input = item.toolArgs;
1773
1895
  execution.artifacts = await extractArtifactsFromResult(item.toolName, item.result);
@@ -1807,7 +1929,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1807
1929
  currentTool: null,
1808
1930
  currentStepStartedAt: null,
1809
1931
  }, {
1810
- verified: true,
1932
+ verified: batchGatheredNewEvidence,
1811
1933
  });
1812
1934
  if (analysis.mode === 'execute' || analysis.mode === 'plan_execute') {
1813
1935
  const iterMeta = engine.getRunMeta(runId);
@@ -2054,11 +2176,20 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2054
2176
  });
2055
2177
  if (lifecycleAfterTool?.action === 'stop' || lifecycleAfterTool?.action === 'interrupt') break;
2056
2178
 
2057
- const execution = classifyToolExecution(toolName, toolArgs, toolResult, toolErrorMessage);
2179
+ const execution = classifyToolExecution(
2180
+ toolName,
2181
+ toolArgs,
2182
+ toolResult,
2183
+ toolErrorMessage,
2184
+ tools.find((tool) => tool?.name === toolName) || null,
2185
+ );
2058
2186
  execution.input = toolArgs;
2059
2187
  const repetitionObservation = repetitionGuard?.observe(toolName, toolArgs, toolResult);
2060
- if ((execution.stateChanged && isProgressToolCall(toolName, toolArgs))
2061
- || gatheredNewEvidence(execution, repetitionObservation)) {
2188
+ const toolMadeConcreteProgress = (
2189
+ (execution.stateChanged && isProgressToolCall(toolName, toolArgs))
2190
+ || gatheredNewEvidence(execution, repetitionObservation)
2191
+ );
2192
+ if (toolMadeConcreteProgress) {
2062
2193
  iterationConcreteProgress = true;
2063
2194
  }
2064
2195
  execution.artifacts = await extractArtifactsFromResult(toolName, toolResult);
@@ -2206,7 +2337,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2206
2337
  currentTool: null,
2207
2338
  currentStepStartedAt: null,
2208
2339
  }, {
2209
- verified: true,
2340
+ verified: toolMadeConcreteProgress,
2210
2341
  stepId,
2211
2342
  });
2212
2343
 
@@ -2256,20 +2387,18 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2256
2387
 
2257
2388
  if (engine.isRunStopped(runId)) {
2258
2389
  const stoppedRunMeta = engine.getRunMeta(runId);
2259
- const terminalStatus = stoppedRunMeta?.status === 'interrupted' ? 'interrupted' : 'stopped';
2390
+ const persistedTerminal = db.prepare(
2391
+ 'SELECT status, error FROM agent_runs WHERE id = ?',
2392
+ ).get(runId);
2393
+ const terminalStatus = persistedTerminal?.status === 'interrupted'
2394
+ || stoppedRunMeta?.status === 'interrupted'
2395
+ ? 'interrupted'
2396
+ : 'stopped';
2260
2397
  const stopReason = stoppedRunMeta?.stopReason || null;
2261
- db.prepare(
2262
- `UPDATE agent_runs
2263
- SET status = ?,
2264
- error = COALESCE(?, error),
2265
- updated_at = datetime('now'),
2266
- completed_at = datetime('now')
2267
- WHERE id = ?`
2268
- ).run(terminalStatus, stopReason, runId);
2269
2398
  console.warn(
2270
2399
  `[Run ${shortenRunId(runId)}] ${terminalStatus} trigger=${triggerSource} steps=${stepIndex} tokens=${totalTokens}`
2271
2400
  );
2272
- engine.cleanupSubagentsForRun(runId, { cancelRunning: true });
2401
+ await engine.cleanupSubagentsForRun(runId, { cancelRunning: true });
2273
2402
  engine.stopMessagingProgressSupervisor(runId);
2274
2403
  engine.activeRuns.delete(runId);
2275
2404
  engine.emit(userId, terminalStatus === 'interrupted' ? 'run:interrupted' : 'run:stopped', {
@@ -2320,16 +2449,20 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2320
2449
  if (budgetExhaustedWithoutCompletion) {
2321
2450
  console.warn(`[Run ${shortenRunId(runId)}] max_iteration_wrapup model=${model} iteration=${iteration}/${maxIterations}`);
2322
2451
  try {
2323
- const wrapResponse = await withModelCallTimeout(
2324
- provider.chat(
2452
+ const wrapResponse = await runAbortableModelCall(
2453
+ (signal) => provider.chat(
2325
2454
  sanitizeConversationMessages([
2326
2455
  ...messages,
2327
2456
  { role: 'system', content: buildMaxIterationWrapupPrompt(options?.source || null) },
2328
2457
  ]),
2329
2458
  [],
2330
- { model, reasoningEffort: engine.getReasoningEffort(providerName, options) },
2459
+ {
2460
+ model,
2461
+ reasoningEffort: engine.getReasoningEffort(providerName, options),
2462
+ signal,
2463
+ },
2331
2464
  ),
2332
- options,
2465
+ { ...options, signal: engine.getRunMeta(runId)?.abortController?.signal },
2333
2466
  'Max-iteration wrap-up',
2334
2467
  );
2335
2468
  totalTokens += wrapResponse.usage?.totalTokens || 0;
@@ -2362,8 +2495,8 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2362
2495
  console.warn(`[Run ${shortenRunId(runId)}] blank_reply_recovery model=${model}`);
2363
2496
  let recoveredTokens = 0;
2364
2497
  try {
2365
- const recoveryResponse = await withModelCallTimeout(
2366
- provider.chat(
2498
+ const recoveryResponse = await runAbortableModelCall(
2499
+ (signal) => provider.chat(
2367
2500
  sanitizeConversationMessages([
2368
2501
  ...messages,
2369
2502
  {
@@ -2374,10 +2507,11 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2374
2507
  [],
2375
2508
  {
2376
2509
  model,
2377
- reasoningEffort: engine.getReasoningEffort(providerName, options)
2510
+ reasoningEffort: engine.getReasoningEffort(providerName, options),
2511
+ signal,
2378
2512
  }
2379
2513
  ),
2380
- options,
2514
+ { ...options, signal: engine.getRunMeta(runId)?.abortController?.signal },
2381
2515
  'Blank messaging reply recovery',
2382
2516
  );
2383
2517
  recoveredTokens = recoveryResponse.usage?.totalTokens || 0;
@@ -2390,7 +2524,9 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2390
2524
  // own wrap-up (it summarizes what it tried / where it got blocked from the run
2391
2525
  // evidence) instead of second-guessing it into a generic blob. Only fall back to
2392
2526
  // a deterministic message when the model returned nothing usable.
2393
- const recoveredVisible = Boolean(normalizeOutgoingMessage(lastContent, options?.source || null));
2527
+ const recoveredVisible = Boolean(
2528
+ normalizeOutgoingMessage(lastContent, options?.source || null),
2529
+ );
2394
2530
  if (!recoveredVisible) {
2395
2531
  lastContent = buildDeterministicMessagingFallback({ failedStepCount, stepIndex, toolExecutions });
2396
2532
  }
@@ -2418,7 +2554,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2418
2554
  || lifecycleBeforeFinalize?.action === 'interrupt'
2419
2555
  ) {
2420
2556
  const terminal = db.prepare('SELECT status FROM agent_runs WHERE id = ?').get(runId)?.status || 'stopped';
2421
- engine.cleanupSubagentsForRun(runId, { cancelRunning: true });
2557
+ await engine.cleanupSubagentsForRun(runId, { cancelRunning: true });
2422
2558
  engine.stopMessagingProgressSupervisor(runId);
2423
2559
  engine.activeRuns.delete(runId);
2424
2560
  return { runId, content: '', totalTokens, iterations: iteration, status: terminal };
@@ -2536,6 +2672,9 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2536
2672
  });
2537
2673
  }
2538
2674
 
2675
+ // Final reply terminality is decided by the AI completion judge / task_complete path.
2676
+ // Do not phrase-match natural-language drafts here.
2677
+
2539
2678
  if (deliverableWorkflow && deliverablePlan) {
2540
2679
  engine.recordRunEvent(userId, runId, 'deliverable_validation_started', {
2541
2680
  type: deliverableWorkflow.selection.type,
@@ -2580,8 +2719,23 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2580
2719
  db.prepare('UPDATE conversations SET total_tokens = total_tokens + ?, updated_at = datetime(\'now\') WHERE id = ?')
2581
2720
  .run(totalTokens, conversationId);
2582
2721
  if (options.skipConversationMaintenance !== true) {
2583
- refreshConversationSummary(conversationId, provider, model, historyWindow).catch((err) => {
2584
- console.error('[AI] Conversation summary refresh failed:', err.message);
2722
+ engine.trackBackgroundTask(
2723
+ (signal) => refreshConversationSummary(
2724
+ conversationId,
2725
+ provider,
2726
+ model,
2727
+ historyWindow,
2728
+ false,
2729
+ { signal },
2730
+ ),
2731
+ {
2732
+ key: `conversation-summary:${conversationId}`,
2733
+ signal: runMeta?.abortController?.signal || null,
2734
+ },
2735
+ ).catch((err) => {
2736
+ if (!isAbortError(err, runMeta?.abortController?.signal) && !engine.shuttingDown) {
2737
+ console.error('[AI] Conversation summary refresh failed:', err.message);
2738
+ }
2585
2739
  });
2586
2740
  }
2587
2741
  }
@@ -2603,6 +2757,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2603
2757
  // Fallback: if this was a messaging-triggered run and no final delivery
2604
2758
  // was already sent in this run, auto-send the final assistant text.
2605
2759
  // Interim progress updates do not suppress this final delivery.
2760
+ await engine.stopMessagingProgressSupervisor(runId);
2606
2761
  if (triggerSource === 'messaging' && options.source && options.chatId) {
2607
2762
  if (engine.shouldSendMessagingFinalFallback(runMeta, lastContent || '', options.source) && !lastFinalDeliveryMessage) {
2608
2763
  await engine.deliverMessagingFinalFallback({
@@ -2622,7 +2777,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2622
2777
  });
2623
2778
  if (!completionWon) {
2624
2779
  const terminal = db.prepare('SELECT status FROM agent_runs WHERE id = ?').get(runId)?.status || 'stopped';
2625
- engine.cleanupSubagentsForRun(runId, { cancelRunning: true });
2780
+ await engine.cleanupSubagentsForRun(runId, { cancelRunning: true });
2626
2781
  engine.stopMessagingProgressSupervisor(runId);
2627
2782
  engine.activeRuns.delete(runId);
2628
2783
  return { runId, content: '', totalTokens, iterations: iteration, status: terminal };
@@ -2639,7 +2794,12 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2639
2794
  analysis,
2640
2795
  verification,
2641
2796
  historyWindow,
2642
- options: { ...options, userId, agentId },
2797
+ options: {
2798
+ ...options,
2799
+ userId,
2800
+ agentId,
2801
+ signal: engine.getRunMeta(runId)?.abortController?.signal || null,
2802
+ },
2643
2803
  }).catch((err) => {
2644
2804
  console.error('[AI] Conversation working state refresh failed:', err.message);
2645
2805
  });
@@ -2648,7 +2808,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2648
2808
  console.info(
2649
2809
  `[Run ${shortenRunId(runId)}] completed trigger=${triggerSource} steps=${stepIndex} tokens=${totalTokens} durationMs=${runMeta?.startedAt ? Date.now() - runMeta.startedAt : 0} finalResponse=${finalResponseText ? 'yes' : 'no'} sentMessages=${runMeta?.sentMessages?.length || 0}`
2650
2810
  );
2651
- engine.cleanupSubagentsForRun(runId, { cancelRunning: true });
2811
+ await engine.cleanupSubagentsForRun(runId, { cancelRunning: true });
2652
2812
  engine.stopMessagingProgressSupervisor(runId);
2653
2813
  engine.activeRuns.delete(runId);
2654
2814
  engine.emit(userId, 'run:complete', {
@@ -2681,12 +2841,16 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2681
2841
  // Fire-and-forget: plugins can use this for self-improvement, memory
2682
2842
  // consolidation, analytics, or other post-run housekeeping.
2683
2843
  if (globalHooks.has('on_loop_end')) {
2684
- globalHooks.run('on_loop_end', {
2685
- userId, runId, agentId, status: 'completed',
2686
- iterations: iteration, totalTokens,
2687
- taskAnalysis: analysis,
2688
- finalContent: finalResponseText,
2689
- }).catch(() => {});
2844
+ engine.trackBackgroundTask(
2845
+ (signal) => globalHooks.run('on_loop_end', {
2846
+ userId, runId, agentId, status: 'completed',
2847
+ iterations: iteration, totalTokens,
2848
+ taskAnalysis: analysis,
2849
+ finalContent: finalResponseText,
2850
+ signal,
2851
+ }),
2852
+ { signal: runMeta?.abortController?.signal || null },
2853
+ ).catch(() => {});
2690
2854
  }
2691
2855
  if (engine.learningManager) {
2692
2856
  try {
@@ -2712,25 +2876,37 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2712
2876
 
2713
2877
  return { runId, content: lastContent, totalTokens, iterations: iteration, status: 'completed' };
2714
2878
  } catch (err) {
2879
+ if (!runRecordCreated) throw err;
2715
2880
  if (engine.isRunStopped(runId)) {
2716
- db.prepare('UPDATE agent_runs SET status = ?, updated_at = datetime(\'now\'), completed_at = datetime(\'now\') WHERE id = ?')
2717
- .run('stopped', runId);
2881
+ const stoppedRunMeta = engine.getRunMeta(runId);
2882
+ const persistedTerminal = db.prepare(
2883
+ 'SELECT status, error FROM agent_runs WHERE id = ?',
2884
+ ).get(runId);
2885
+ const terminalStatus = persistedTerminal?.status === 'interrupted'
2886
+ || stoppedRunMeta?.status === 'interrupted'
2887
+ ? 'interrupted'
2888
+ : 'stopped';
2718
2889
  console.warn(
2719
- `[Run ${shortenRunId(runId)}] stopped trigger=${triggerSource} steps=${stepIndex} tokens=${totalTokens}`
2890
+ `[Run ${shortenRunId(runId)}] ${terminalStatus} trigger=${triggerSource} steps=${stepIndex} tokens=${totalTokens}`
2720
2891
  );
2721
- engine.cleanupSubagentsForRun(runId, { cancelRunning: true });
2892
+ await engine.cleanupSubagentsForRun(runId, { cancelRunning: true });
2722
2893
  engine.stopMessagingProgressSupervisor(runId);
2723
2894
  engine.activeRuns.delete(runId);
2724
- engine.emit(userId, 'run:stopped', { runId, triggerSource });
2725
- engine.recordRunEvent(userId, runId, 'run_stopped', {
2895
+ engine.emit(userId, terminalStatus === 'interrupted' ? 'run:interrupted' : 'run:stopped', {
2896
+ runId,
2897
+ triggerSource,
2898
+ reason: stoppedRunMeta?.stopReason || persistedTerminal?.error || null,
2899
+ });
2900
+ engine.recordRunEvent(userId, terminalStatus === 'interrupted' ? 'run_interrupted' : 'run_stopped', {
2726
2901
  triggerSource,
2727
2902
  totalTokens,
2728
2903
  iterations: iteration,
2729
2904
  }, { agentId });
2730
- return { runId, content: '', totalTokens, iterations: iteration, status: 'stopped' };
2905
+ return { runId, content: '', totalTokens, iterations: iteration, status: terminalStatus };
2731
2906
  }
2732
2907
 
2733
2908
  const runMeta = engine.activeRuns.get(runId);
2909
+ await engine.stopMessagingProgressSupervisor(runId);
2734
2910
 
2735
2911
  const deliverableFailureResponse = err?.deliverableResult?.summary
2736
2912
  || err?.deliverableValidation?.summary
@@ -2754,16 +2930,19 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2754
2930
  content: `The run encountered a runtime error and cannot continue reliably. Use the actual run scenario below to explain the blocker naturally.\n\nScenario:\n${failureScenario || 'No additional scenario details were captured.'}\n\nDo not call tools. Write exactly one short user message. Do not ask the user to resend or restate the same task. Only ask the user for something if a specific external input, permission, or configuration change is actually required. Do not promise future work unless it will happen automatically before this reply is sent.\n\n${buildPlatformFormattingGuide(options?.source || null)}`
2755
2931
  }
2756
2932
  ]);
2757
- const modelReply = await withModelCallTimeout(
2758
- provider.chat(failedMessage, [], {
2933
+ const modelReply = await runAbortableModelCall(
2934
+ (signal) => provider.chat(failedMessage, [], {
2759
2935
  model,
2760
- reasoningEffort: engine.getReasoningEffort(providerName, options)
2936
+ reasoningEffort: engine.getReasoningEffort(providerName, options),
2937
+ signal,
2761
2938
  }),
2762
- options,
2939
+ { ...options, signal: runMeta?.abortController?.signal },
2763
2940
  'Messaging failure reply',
2764
2941
  );
2765
2942
  const drafted = sanitizeModelOutput(modelReply.content || '', { model });
2766
- if (normalizeOutgoingMessage(drafted, options?.source || null)) {
2943
+ if (
2944
+ normalizeOutgoingMessage(drafted, options?.source || null)
2945
+ ) {
2767
2946
  messagingFailureContent = drafted.trim();
2768
2947
  }
2769
2948
  } catch {
@@ -2785,7 +2964,11 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2785
2964
  options.source,
2786
2965
  options.chatId,
2787
2966
  messagingFailureContent,
2788
- { runId, agentId },
2967
+ {
2968
+ runId,
2969
+ agentId,
2970
+ signal: runMeta?.abortController?.signal || null,
2971
+ },
2789
2972
  );
2790
2973
  requireSuccessfulMessagingDelivery(deliveryResult, 'Messaging failure delivery');
2791
2974
  sendSucceeded = true;
@@ -2810,7 +2993,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2810
2993
  totalTokens,
2811
2994
  });
2812
2995
  if (!failureWon) {
2813
- engine.cleanupSubagentsForRun(runId, { cancelRunning: true });
2996
+ await engine.cleanupSubagentsForRun(runId, { cancelRunning: true });
2814
2997
  engine.stopMessagingProgressSupervisor(runId);
2815
2998
  engine.activeRuns.delete(runId);
2816
2999
  const terminal = db.prepare('SELECT status FROM agent_runs WHERE id = ?').get(runId)?.status || 'stopped';
@@ -2820,7 +3003,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2820
3003
  `[Run ${shortenRunId(runId)}] failed trigger=${triggerSource} steps=${stepIndex} tokens=${totalTokens} error=${summarizeForLog(err.message, 180)}`
2821
3004
  );
2822
3005
 
2823
- engine.cleanupSubagentsForRun(runId, { cancelRunning: true });
3006
+ await engine.cleanupSubagentsForRun(runId, { cancelRunning: true });
2824
3007
  engine.stopMessagingProgressSupervisor(runId);
2825
3008
  engine.activeRuns.delete(runId);
2826
3009
  engine.emit(userId, 'run:error', { runId, error: err.message });
@@ -2830,17 +3013,6 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2830
3013
  iterations: iteration,
2831
3014
  deliverableType: deliverableWorkflow?.selection?.type || null,
2832
3015
  }, { agentId });
2833
- timelineService?.recordRunLifecycle?.({
2834
- userId,
2835
- agentId,
2836
- runId,
2837
- title: runTitle,
2838
- eventKind: 'run_failed',
2839
- status: 'failed',
2840
- triggerSource,
2841
- error: err.message,
2842
- });
2843
-
2844
3016
  if (messagingFailureContent) {
2845
3017
  return {
2846
3018
  runId,
@@ -2853,6 +3025,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2853
3025
 
2854
3026
  throw err;
2855
3027
  } finally {
3028
+ detachExternalAbort?.();
2856
3029
  releaseReservation();
2857
3030
  }
2858
3031
  }