neoagent 3.2.1-beta.0 → 3.2.1-beta.10

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 (175) hide show
  1. package/docs/agent-run-lifecycle.md +10 -0
  2. package/extensions/chrome-browser/background.mjs +318 -88
  3. package/extensions/chrome-browser/http.mjs +136 -0
  4. package/extensions/chrome-browser/protocol.mjs +654 -90
  5. package/flutter_app/lib/main_chat.dart +118 -739
  6. package/flutter_app/lib/main_controller.dart +111 -20
  7. package/flutter_app/lib/main_integrations.dart +607 -8
  8. package/flutter_app/lib/main_models.dart +3 -0
  9. package/flutter_app/lib/main_operations.dart +334 -321
  10. package/flutter_app/lib/main_security.dart +266 -112
  11. package/flutter_app/lib/main_settings.dart +4 -3
  12. package/flutter_app/lib/main_shared.dart +14 -11
  13. package/flutter_app/lib/src/backend_client.dart +78 -0
  14. package/flutter_app/lib/src/desktop_companion_actions.dart +185 -31
  15. package/flutter_app/lib/src/desktop_companion_io.dart +319 -86
  16. package/flutter_app/windows/runner/flutter_window.cpp +143 -32
  17. package/landing/index.html +3 -1
  18. package/lib/manager.js +106 -89
  19. package/lib/schema_migrations.js +145 -13
  20. package/package.json +30 -15
  21. package/runtime/paths.js +49 -5
  22. package/server/db/database.js +4 -4
  23. package/server/guest-agent.cli.package.json +13 -0
  24. package/server/guest_agent.js +85 -40
  25. package/server/http/middleware.js +24 -0
  26. package/server/http/routes.js +11 -6
  27. package/server/public/.last_build_id +1 -1
  28. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  29. package/server/public/flutter_bootstrap.js +2 -2
  30. package/server/public/main.dart.js +73083 -72209
  31. package/server/routes/admin.js +1 -1
  32. package/server/routes/agents.js +35 -2
  33. package/server/routes/android.js +30 -34
  34. package/server/routes/browser.js +23 -15
  35. package/server/routes/desktop.js +18 -1
  36. package/server/routes/integrations.js +107 -1
  37. package/server/routes/memory.js +1 -0
  38. package/server/routes/settings.js +16 -5
  39. package/server/routes/social_reach.js +12 -3
  40. package/server/routes/social_video.js +4 -0
  41. package/server/services/agents/manager.js +1 -1
  42. package/server/services/ai/capabilityHealth.js +62 -96
  43. package/server/services/ai/compaction.js +7 -2
  44. package/server/services/ai/history.js +45 -6
  45. package/server/services/ai/integrated_tools/http_request.js +8 -0
  46. package/server/services/ai/loop/agent_engine_core.js +496 -166
  47. package/server/services/ai/loop/blank_recovery.js +5 -4
  48. package/server/services/ai/loop/callbacks.js +1 -0
  49. package/server/services/ai/loop/completion_judge.js +121 -5
  50. package/server/services/ai/loop/conversation_loop.js +620 -340
  51. package/server/services/ai/loop/lifecycle.js +108 -0
  52. package/server/services/ai/loop/messaging_delivery.js +129 -57
  53. package/server/services/ai/loop/model_call_guard.js +91 -0
  54. package/server/services/ai/loop/model_io.js +48 -56
  55. package/server/services/ai/loop/progress_classification.js +2 -0
  56. package/server/services/ai/loop/tool_dispatch.js +28 -8
  57. package/server/services/ai/loopPolicy.js +48 -21
  58. package/server/services/ai/messagingFallback.js +17 -17
  59. package/server/services/ai/model_discovery.js +227 -0
  60. package/server/services/ai/model_failure_cache.js +108 -0
  61. package/server/services/ai/model_identity.js +71 -0
  62. package/server/services/ai/models.js +68 -163
  63. package/server/services/ai/providerRetry.js +17 -59
  64. package/server/services/ai/provider_selector.js +166 -0
  65. package/server/services/ai/providers/anthropic.js +4 -4
  66. package/server/services/ai/providers/claudeCode.js +21 -33
  67. package/server/services/ai/providers/githubCopilot.js +41 -20
  68. package/server/services/ai/providers/google.js +135 -97
  69. package/server/services/ai/providers/grok.js +6 -5
  70. package/server/services/ai/providers/grokOauth.js +19 -27
  71. package/server/services/ai/providers/nvidia.js +12 -7
  72. package/server/services/ai/providers/ollama.js +114 -86
  73. package/server/services/ai/providers/ollama_stream.js +142 -0
  74. package/server/services/ai/providers/openai.js +41 -7
  75. package/server/services/ai/providers/openaiCodex.js +13 -4
  76. package/server/services/ai/providers/openrouter.js +31 -9
  77. package/server/services/ai/providers/provider_error.js +36 -0
  78. package/server/services/ai/settings.js +26 -2
  79. package/server/services/ai/systemPrompt.js +19 -12
  80. package/server/services/ai/taskAnalysis.js +58 -10
  81. package/server/services/ai/terminal_reply.js +18 -0
  82. package/server/services/ai/toolEvidence.js +350 -29
  83. package/server/services/ai/tools.js +190 -111
  84. package/server/services/android/controller.js +770 -237
  85. package/server/services/android/process.js +140 -0
  86. package/server/services/android/sdk_download.js +143 -0
  87. package/server/services/android/uia.js +6 -5
  88. package/server/services/artifacts/store.js +24 -0
  89. package/server/services/browser/controller.js +843 -385
  90. package/server/services/browser/extension/gateway.js +40 -16
  91. package/server/services/browser/extension/protocol.js +15 -1
  92. package/server/services/browser/extension/provider.js +71 -47
  93. package/server/services/browser/extension/registry.js +155 -34
  94. package/server/services/cli/executor.js +62 -9
  95. package/server/services/credentials/bitwarden_cli.js +322 -0
  96. package/server/services/credentials/broker.js +594 -0
  97. package/server/services/desktop/gateway.js +41 -4
  98. package/server/services/desktop/protocol.js +3 -0
  99. package/server/services/desktop/provider.js +39 -42
  100. package/server/services/desktop/registry.js +137 -52
  101. package/server/services/integrations/bitwarden/constants.js +14 -0
  102. package/server/services/integrations/bitwarden/provider.js +197 -0
  103. package/server/services/integrations/bitwarden/snapshot.js +65 -0
  104. package/server/services/integrations/figma/provider.js +78 -12
  105. package/server/services/integrations/github/common.js +11 -6
  106. package/server/services/integrations/github/provider.js +52 -53
  107. package/server/services/integrations/google/provider.js +55 -19
  108. package/server/services/integrations/home_assistant/network.js +17 -20
  109. package/server/services/integrations/home_assistant/provider.js +7 -5
  110. package/server/services/integrations/home_assistant/tools.js +17 -5
  111. package/server/services/integrations/http.js +51 -0
  112. package/server/services/integrations/manager.js +159 -53
  113. package/server/services/integrations/microsoft/provider.js +80 -13
  114. package/server/services/integrations/neoarchive/provider.js +55 -29
  115. package/server/services/integrations/neorecall/client.js +17 -10
  116. package/server/services/integrations/neorecall/provider.js +20 -11
  117. package/server/services/integrations/notion/provider.js +16 -13
  118. package/server/services/integrations/oauth_provider.js +115 -51
  119. package/server/services/integrations/registry.js +2 -0
  120. package/server/services/integrations/slack/provider.js +98 -9
  121. package/server/services/integrations/spotify/provider.js +67 -71
  122. package/server/services/integrations/trello/provider.js +21 -7
  123. package/server/services/integrations/weather/provider.js +18 -12
  124. package/server/services/integrations/whatsapp/provider.js +76 -16
  125. package/server/services/manager.js +110 -1
  126. package/server/services/memory/embedding_index.js +20 -8
  127. package/server/services/memory/embeddings.js +151 -90
  128. package/server/services/memory/ingestion.js +50 -9
  129. package/server/services/memory/ingestion_documents.js +13 -3
  130. package/server/services/memory/manager.js +52 -19
  131. package/server/services/messaging/automation.js +85 -10
  132. package/server/services/messaging/formatting_guides.js +7 -4
  133. package/server/services/messaging/http_platforms.js +33 -13
  134. package/server/services/messaging/inbound_queue.js +78 -24
  135. package/server/services/messaging/inbound_store.js +224 -0
  136. package/server/services/messaging/manager.js +326 -51
  137. package/server/services/messaging/typing_keepalive.js +5 -2
  138. package/server/services/messaging/whatsapp.js +22 -14
  139. package/server/services/network/http.js +210 -0
  140. package/server/services/network/safe_request.js +307 -0
  141. package/server/services/runtime/backends/local-vm.js +227 -67
  142. package/server/services/runtime/docker-vm-manager.js +9 -0
  143. package/server/services/runtime/guest_bootstrap.js +30 -4
  144. package/server/services/runtime/guest_image.js +43 -12
  145. package/server/services/runtime/manager.js +77 -23
  146. package/server/services/runtime/validation.js +7 -6
  147. package/server/services/security/tool_categories.js +6 -0
  148. package/server/services/social_reach/channels/github.js +10 -4
  149. package/server/services/social_reach/channels/reddit.js +4 -4
  150. package/server/services/social_reach/channels/rss.js +2 -2
  151. package/server/services/social_reach/channels/social_video.js +12 -7
  152. package/server/services/social_reach/channels/v2ex.js +21 -8
  153. package/server/services/social_reach/channels/x.js +2 -2
  154. package/server/services/social_reach/channels/xueqiu.js +5 -5
  155. package/server/services/social_reach/service.js +9 -6
  156. package/server/services/social_reach/utils.js +65 -14
  157. package/server/services/social_video/service.js +160 -50
  158. package/server/services/tasks/integration_runtime.js +18 -8
  159. package/server/services/tasks/runtime.js +39 -4
  160. package/server/services/voice/agentBridge.js +17 -4
  161. package/server/services/voice/bufferedLiveRelayAdapter.js +5 -0
  162. package/server/services/voice/liveSession.js +31 -0
  163. package/server/services/voice/message.js +1 -1
  164. package/server/services/voice/openaiSpeech.js +33 -8
  165. package/server/services/voice/providers.js +233 -151
  166. package/server/services/voice/runtime.js +2 -2
  167. package/server/services/voice/runtimeManager.js +118 -20
  168. package/server/services/voice/turnRunner.js +6 -0
  169. package/server/services/wearable/firmware_manifest.js +51 -13
  170. package/server/services/wearable/service.js +1 -0
  171. package/server/utils/abort.js +96 -0
  172. package/server/utils/cloud-security.js +110 -3
  173. package/server/utils/files.js +31 -0
  174. package/server/utils/image_payload.js +95 -0
  175. 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,6 +780,8 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
709
780
  voiceSessionId: options.voiceSessionId || null,
710
781
  steeringQueue: [],
711
782
  systemSteeringQueue: [],
783
+ abortController: startupAbortController,
784
+ pauseAvailable: false,
712
785
  toolPids: new Set(),
713
786
  subagentDepth: Math.max(0, Number(options.subagentDepth) || 0),
714
787
  repetitionGuard: new ToolRepetitionGuard(),
@@ -733,10 +806,21 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
733
806
  goalContract: carriedGoalContract,
734
807
  });
735
808
  engine.startMessagingProgressSupervisor(runId);
736
- engine.emit(userId, 'run:start', { runId, agentId, title: runTitle, model, triggerType, triggerSource });
809
+ engine.emit(userId, 'run:start', {
810
+ runId,
811
+ agentId,
812
+ title: runTitle,
813
+ model,
814
+ modelSelectionId,
815
+ provider: providerName,
816
+ triggerType,
817
+ triggerSource,
818
+ });
737
819
  engine.recordRunEvent(userId, runId, 'run_started', {
738
820
  title: runTitle,
739
821
  model,
822
+ modelSelectionId,
823
+ provider: providerName,
740
824
  triggerType,
741
825
  triggerSource,
742
826
  }, { agentId });
@@ -750,7 +834,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
750
834
  triggerSource,
751
835
  });
752
836
  console.info(
753
- `[Run ${shortenRunId(runId)}] started trigger=${triggerSource} type=${triggerType} model=${model} title=${summarizeForLog(runTitle, 120)}`
837
+ `[Run ${shortenRunId(runId)}] started trigger=${triggerSource} type=${triggerType} model=${modelSelectionId} title=${summarizeForLog(runTitle, 120)}`
754
838
  );
755
839
 
756
840
  const systemPrompt = await engine.buildSystemPrompt(userId, {
@@ -881,7 +965,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
881
965
  if (triggerSource === 'messaging') {
882
966
  const runMetaForCompose = engine.getRunMeta(runId);
883
967
  if (runMetaForCompose) {
884
- runMetaForCompose.composeProgressUpdate = async ({ stalled = false } = {}) => {
968
+ runMetaForCompose.composeProgressUpdate = async ({ stalled = false, signal = null } = {}) => {
885
969
  try {
886
970
  const rm = engine.getRunMeta(runId);
887
971
  const ledger = rm?.progressLedger || {};
@@ -898,7 +982,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
898
982
  const priorUpdate = String(rm?.lastInterimMessage || '').trim();
899
983
  const contextBlock = [
900
984
  buildProgressUpdatePrompt(),
901
- stalled ? 'This step has been running a while with no new progress; reassure the user you are still on it.' : '',
985
+ 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.' : '',
902
986
  '',
903
987
  `Original request: ${summarizeForLog(userMessage, 320)}`,
904
988
  currentTool ? `Doing now: using ${currentTool}` : '',
@@ -908,17 +992,24 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
908
992
  // Reuse the run's real system prompt so the update follows the same voice and
909
993
  // formatting guidelines as every other message (single source of truth).
910
994
  const sysContent = [systemPrompt?.stable, systemPrompt?.dynamic].filter(Boolean).join('\n\n')
911
- || 'You are a helpful assistant.';
912
- const resp = await withModelCallTimeout(
913
- provider.chat(
995
+ || 'You are the user\'s favorite contact: warm, direct, and competent.';
996
+ const resp = await runAbortableModelCall(
997
+ (signal) => provider.chat(
914
998
  [
915
999
  { role: 'system', content: sysContent },
916
1000
  { role: 'user', content: contextBlock },
917
1001
  ],
918
1002
  [],
919
- { model, reasoningEffort: engine.getReasoningEffort(providerName, options) },
1003
+ {
1004
+ model,
1005
+ reasoningEffort: engine.getReasoningEffort(providerName, options),
1006
+ signal,
1007
+ },
920
1008
  ),
921
- options,
1009
+ {
1010
+ ...options,
1011
+ signals: [rm?.abortController?.signal, signal],
1012
+ },
922
1013
  'Progress update compose',
923
1014
  );
924
1015
  return sanitizeModelOutput(resp.content || '', { model });
@@ -1029,6 +1120,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1029
1120
  null,
1030
1121
  {
1031
1122
  ...providerStatusConfig,
1123
+ signal: engine.getRunMeta(runId)?.abortController?.signal,
1032
1124
  selectionHint: {
1033
1125
  purpose: requestedPurpose,
1034
1126
  complexity: analysis?.complexity,
@@ -1038,15 +1130,21 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1038
1130
  },
1039
1131
  }
1040
1132
  );
1041
- if (selectedAfterAnalysis.model !== model) {
1133
+ if (selectedAfterAnalysis.modelSelectionId !== modelSelectionId) {
1042
1134
  provider = selectedAfterAnalysis.provider;
1043
1135
  model = selectedAfterAnalysis.model;
1136
+ modelSelectionId = selectedAfterAnalysis.modelSelectionId;
1044
1137
  providerName = selectedAfterAnalysis.providerName;
1045
1138
  db.prepare('UPDATE agent_runs SET model = ?, updated_at = datetime(\'now\') WHERE id = ?')
1046
- .run(model, runId);
1139
+ .run(modelSelectionId, runId);
1140
+ Object.assign(engine.getRunMeta(runId) || {}, {
1141
+ model,
1142
+ modelSelectionId,
1143
+ providerName,
1144
+ });
1047
1145
  engine.emit(userId, 'run:interim', {
1048
1146
  runId,
1049
- message: `Switched to ${model} for this run after task analysis.`,
1147
+ message: `Switched to ${modelSelectionId} for this run after task analysis.`,
1050
1148
  phase: 'model_selection'
1051
1149
  });
1052
1150
  }
@@ -1207,8 +1305,15 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1207
1305
  // regardless of which iteration they fall in.
1208
1306
  let consecutiveToolFailures = 0;
1209
1307
  const iterationBudget = new IterationBudget(maxIterations);
1308
+ const activeRunMeta = engine.getRunMeta(runId);
1309
+ if (activeRunMeta) activeRunMeta.pauseAvailable = !directAnswerEligible;
1210
1310
 
1211
1311
  while (!directAnswerEligible && iterationBudget.consume()) {
1312
+ const lifecycleAtStart = await engine.checkpointLifecycle(runId, 'iteration_boundary', {
1313
+ iteration: iterationBudget.used,
1314
+ stepIndex,
1315
+ });
1316
+ if (lifecycleAtStart?.action === 'stop' || lifecycleAtStart?.action === 'interrupt') break;
1212
1317
  if (engine.isRunStopped(runId)) break;
1213
1318
  iteration = iterationBudget.used;
1214
1319
 
@@ -1230,7 +1335,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1230
1335
  reason,
1231
1336
  stoppedBy: hookResult.stopped_by || hookResult.stoppedBy || null,
1232
1337
  }, { agentId });
1233
- lastContent = reason;
1338
+ engine.stopRun(runId, reason);
1234
1339
  break;
1235
1340
  }
1236
1341
  const systemSteering = String(hookResult?.systemSteering || hookResult?.system_steering || '').trim();
@@ -1289,6 +1394,47 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1289
1394
  continue;
1290
1395
  }
1291
1396
  alreadyRead = summarizeReadTargets(toolExecutions);
1397
+ const researchAdequacyAtCap = assessResearchAdequacy({
1398
+ analysis,
1399
+ goalContext: runGoalCtx,
1400
+ toolExecutions,
1401
+ });
1402
+ // Incomplete multi-target research is not "stuck" yet if primary sources
1403
+ // remain uncovered. Give one guided research continuation instead of
1404
+ // force-finishing with a guessed comparison.
1405
+ if (
1406
+ researchAdequacyAtCap.adequate === false
1407
+ && researchAdequacyAtCap.intensity !== 'none'
1408
+ && iterMeta
1409
+ && iterMeta.researchAdequacyDeferralUsed !== true
1410
+ && (
1411
+ researchAdequacyAtCap.uncoveredTargets.length > 0
1412
+ || researchAdequacyAtCap.primarySourceCount < researchAdequacyAtCap.requiredPrimarySources
1413
+ )
1414
+ ) {
1415
+ iterMeta.researchAdequacyDeferralUsed = true;
1416
+ iterMeta.consecutiveReadOnlyIterations = Math.max(0, churnNudgeThreshold - 1);
1417
+ messages.push({
1418
+ role: 'system',
1419
+ content: [
1420
+ 'Research self-check: evidence is still incomplete, so this run must not force-finish yet.',
1421
+ researchAdequacyAtCap.reason ? `Gap: ${researchAdequacyAtCap.reason}` : '',
1422
+ researchAdequacyAtCap.nextActions?.length
1423
+ ? `Next safe steps:\n- ${researchAdequacyAtCap.nextActions.join('\n- ')}`
1424
+ : '',
1425
+ 'Open or fetch primary sources for uncovered targets now. Do not invent the missing comparison from memory or search snippets alone.',
1426
+ ].filter(Boolean).join('\n'),
1427
+ });
1428
+ engine.recordRunEvent(userId, runId, 'read_only_wrapup_deferred_for_research', {
1429
+ iteration,
1430
+ readOnlyCount,
1431
+ intensity: researchAdequacyAtCap.intensity,
1432
+ uncoveredTargets: researchAdequacyAtCap.uncoveredTargets,
1433
+ primarySourceCount: researchAdequacyAtCap.primarySourceCount,
1434
+ requiredPrimarySources: researchAdequacyAtCap.requiredPrimarySources,
1435
+ }, { agentId });
1436
+ continue;
1437
+ }
1292
1438
  triggerForceWrapup = true;
1293
1439
  } else if (readOnlyCount >= churnNudgeThreshold) {
1294
1440
  alreadyRead = summarizeReadTargets(toolExecutions);
@@ -1353,8 +1499,8 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1353
1499
  });
1354
1500
  let wrapTokens = 0;
1355
1501
  try {
1356
- const wrapResponse = await withModelCallTimeout(
1357
- provider.chat(
1502
+ const wrapResponse = await runAbortableModelCall(
1503
+ (signal) => provider.chat(
1358
1504
  sanitizeConversationMessages([
1359
1505
  ...messages,
1360
1506
  {
@@ -1363,13 +1509,22 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1363
1509
  readOnlyCount,
1364
1510
  alreadyRead,
1365
1511
  platform: options?.source || null,
1512
+ researchAdequacy: assessResearchAdequacy({
1513
+ analysis,
1514
+ goalContext: runGoalCtx,
1515
+ toolExecutions,
1516
+ }),
1366
1517
  }),
1367
1518
  },
1368
1519
  ]),
1369
1520
  [],
1370
- { model, reasoningEffort: engine.getReasoningEffort(providerName, options) },
1521
+ {
1522
+ model,
1523
+ reasoningEffort: engine.getReasoningEffort(providerName, options),
1524
+ signal,
1525
+ },
1371
1526
  ),
1372
- options,
1527
+ { ...options, signal: engine.getRunMeta(runId)?.abortController?.signal },
1373
1528
  'No-progress wrap-up',
1374
1529
  );
1375
1530
  wrapTokens = wrapResponse.usage?.totalTokens || 0;
@@ -1422,9 +1577,9 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1422
1577
  let metrics = engine.estimatePromptMetrics(messages, tools);
1423
1578
  const contextWindow = provider.getContextWindow(model);
1424
1579
  if (metrics.totalEstimatedTokens > contextWindow * loopPolicy.compactionThreshold) {
1425
- messages = await withModelCallTimeout(
1426
- compact(messages, provider, model, contextWindow),
1427
- options,
1580
+ messages = await runAbortableModelCall(
1581
+ (signal) => compact(messages, provider, model, contextWindow, { signal }),
1582
+ { ...options, signal: engine.getRunMeta(runId)?.abortController?.signal },
1428
1583
  `Context compaction before iteration ${iteration}`,
1429
1584
  );
1430
1585
  messages = sanitizeConversationMessages(messages);
@@ -1444,41 +1599,59 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1444
1599
  let responseModel = model;
1445
1600
  let streamContent = '';
1446
1601
 
1447
- const tryModelCall = async (retryForFallback = true) => {
1448
- try {
1449
- const modelCall = await engine.requestModelResponse({
1450
- provider,
1451
- providerName,
1452
- model,
1453
- messages,
1454
- tools,
1455
- options: { ...options, userId, agentId, runId, phase: 'model_turn' },
1456
- runId,
1457
- iteration,
1458
- });
1459
- response = modelCall.response;
1460
- responseModel = modelCall.responseModel;
1461
- streamContent = modelCall.streamContent;
1462
- } catch (err) {
1463
- console.error(`[Engine] Model call failed (${model}):`, err.message);
1464
- const fallbackModelId = retryForFallback
1465
- ? await getFailureFallbackModelId(userId, agentId, model, aiSettings.fallback_model_id, err)
1466
- : null;
1467
- if (fallbackModelId) {
1602
+ const tryModelCall = async () => {
1603
+ let requestMessages = messages;
1604
+ let requestPhase = 'model_turn';
1605
+ while (true) {
1606
+ try {
1607
+ const modelCall = await engine.requestModelResponse({
1608
+ provider,
1609
+ providerName,
1610
+ model,
1611
+ messages: requestMessages,
1612
+ tools,
1613
+ options: {
1614
+ ...options,
1615
+ userId,
1616
+ agentId,
1617
+ runId,
1618
+ phase: requestPhase,
1619
+ signal: engine.getRunMeta(runId)?.abortController?.signal,
1620
+ },
1621
+ runId,
1622
+ iteration,
1623
+ });
1624
+ response = modelCall.response;
1625
+ responseModel = modelCall.responseModel;
1626
+ streamContent = modelCall.streamContent;
1627
+ recordModelSuccess(userId, agentId, modelSelectionId);
1628
+ return;
1629
+ } catch (err) {
1630
+ console.error(`[Engine] Model call failed (${model}):`, err.message);
1631
+ const runSignal = engine.getRunMeta(runId)?.abortController?.signal;
1632
+ if (isAbortError(err) || runSignal?.aborted) throw err;
1633
+ if (modelFailureRecoveries >= loopPolicy.maxModelFailureRecoveries) {
1634
+ recordFailedModel(
1635
+ modelSelectionId,
1636
+ err,
1637
+ modelTurnFailedModelSelectionIds,
1638
+ );
1639
+ throw err;
1640
+ }
1641
+
1468
1642
  const failedModel = model;
1469
- console.log(`[Engine] Attempting fallback to: ${fallbackModelId}`);
1470
- const fallback = await getProviderForUser(
1471
- userId,
1472
- userMessage,
1473
- triggerType === 'subagent',
1474
- fallbackModelId,
1475
- providerStatusConfig
1643
+ const switched = await switchToFallbackModel(
1644
+ modelSelectionId,
1645
+ err,
1646
+ 'model turn',
1647
+ modelTurnFailedModelSelectionIds,
1476
1648
  );
1477
- provider = fallback.provider;
1478
- model = fallback.model;
1479
- providerName = fallback.providerName;
1649
+ if (!switched) throw err;
1480
1650
 
1481
- const retryMessages = sanitizeConversationMessages([
1651
+ modelFailureRecoveries += 1;
1652
+ failedStepCount += 1;
1653
+ requestPhase = 'model_turn_fallback';
1654
+ requestMessages = sanitizeConversationMessages([
1482
1655
  ...messages,
1483
1656
  {
1484
1657
  role: 'system',
@@ -1489,22 +1662,6 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1489
1662
  })
1490
1663
  }
1491
1664
  ]);
1492
-
1493
- const fallbackCall = await engine.requestModelResponse({
1494
- provider,
1495
- providerName,
1496
- model,
1497
- messages: retryMessages,
1498
- tools,
1499
- options: { ...options, userId },
1500
- runId,
1501
- iteration,
1502
- });
1503
- response = fallbackCall.response;
1504
- responseModel = fallbackCall.responseModel;
1505
- streamContent = fallbackCall.streamContent;
1506
- } else {
1507
- throw err;
1508
1665
  }
1509
1666
  }
1510
1667
  };
@@ -1512,30 +1669,21 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1512
1669
  try {
1513
1670
  await tryModelCall();
1514
1671
  } catch (err) {
1515
- const modelError = String(err?.message || 'Model call failed');
1516
-
1517
- if (modelFailureRecoveries < loopPolicy.maxModelFailureRecoveries) {
1518
- const failedModel = model;
1519
- const switched = await switchToFallbackModel(failedModel, err, 'model turn');
1520
- if (!switched) throw err;
1521
- modelFailureRecoveries += 1;
1522
- failedStepCount += 1;
1523
- messages.push({
1524
- role: 'system',
1525
- content: buildModelFailureLoopPrompt({
1526
- failedModel,
1527
- nextModel: model,
1528
- errorMessage: modelError
1529
- })
1530
- });
1531
- engine.emit(userId, 'run:interim', {
1532
- runId,
1533
- message: 'Model call failed; adapting and retrying autonomously.',
1534
- phase: 'recovering'
1535
- });
1672
+ const lifecycleAbort = err?.name === 'AbortError'
1673
+ || err?.code === 'ABORT_ERR'
1674
+ || /abort/i.test(String(err?.name || ''))
1675
+ || engine.getRunMeta(runId)?.abortController?.signal?.aborted === true;
1676
+ const lifecycleControl = await engine.checkpointLifecycle(runId, 'model_boundary', {
1677
+ iteration,
1678
+ stepIndex,
1679
+ });
1680
+ if (engine.isRunStopped(runId)) break;
1681
+ if (lifecycleAbort && !lifecycleControl && engine.getRunMeta(runId)?.status === 'running') {
1682
+ iterationBudget.refund();
1683
+ iteration = iterationBudget.used;
1536
1684
  continue;
1537
1685
  }
1538
-
1686
+ if (lifecycleControl?.action === 'stop' || lifecycleControl?.action === 'interrupt') break;
1539
1687
  throw err;
1540
1688
  }
1541
1689
 
@@ -1543,6 +1691,12 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1543
1691
  response = { content: streamContent, toolCalls: [], finishReason: 'stop', usage: null };
1544
1692
  }
1545
1693
 
1694
+ const lifecycleAfterModel = await engine.checkpointLifecycle(runId, 'model_boundary', {
1695
+ iteration,
1696
+ stepIndex,
1697
+ });
1698
+ if (lifecycleAfterModel?.action === 'stop' || lifecycleAfterModel?.action === 'interrupt') break;
1699
+
1546
1700
  if (response.usage) {
1547
1701
  totalTokens += response.usage.totalTokens || 0;
1548
1702
  }
@@ -1564,10 +1718,6 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1564
1718
  toolCallCount: response.toolCalls?.length || 0,
1565
1719
  contentPreview: String(lastContent || streamContent || '').slice(0, 240),
1566
1720
  }, { agentId });
1567
- engine.updateRunProgress(runId, {}, {
1568
- verified: true,
1569
- });
1570
-
1571
1721
  const assistantMessage = { role: 'assistant', content: lastContent };
1572
1722
  if (response.toolCalls?.length) assistantMessage.tool_calls = response.toolCalls;
1573
1723
  if (response.providerContentBlocks?.length) assistantMessage.providerContentBlocks = response.providerContentBlocks;
@@ -1677,13 +1827,23 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1677
1827
  iteration,
1678
1828
  }, { agentId });
1679
1829
  if (loopDecision.decision.status === 'continue') {
1830
+ const researchGuidance = loopDecision.researchAdequacy?.adequate === false
1831
+ ? [
1832
+ `Research still incomplete: ${loopDecision.researchAdequacy.reason || 'gather primary evidence for remaining targets.'}`,
1833
+ loopDecision.researchAdequacy.nextActions?.length
1834
+ ? `Next safe steps: ${loopDecision.researchAdequacy.nextActions.join(' ')}`
1835
+ : '',
1836
+ ].filter(Boolean).join(' ')
1837
+ : '';
1680
1838
  messages.push({
1681
1839
  role: 'system',
1682
1840
  content: [
1683
1841
  'The run self-check determined the latest assistant text is not terminal.',
1684
1842
  'Continue with the next safe tool/model step.',
1685
1843
  'If the text was only a progress note, do not repeat it; either make progress or provide a real final/blocker reply.',
1686
- ].join(' '),
1844
+ 'Do not invent missing targets, outcomes, or tool results. Gather evidence or return a truthful partial/blocker answer.',
1845
+ researchGuidance,
1846
+ ].filter(Boolean).join(' '),
1687
1847
  });
1688
1848
  lastContent = '';
1689
1849
  continue;
@@ -1694,7 +1854,10 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1694
1854
 
1695
1855
  const canRunParallelBatch = (
1696
1856
  response.toolCalls.length > 1
1697
- && response.toolCalls.every((toolCall) => engine.isReadOnlyToolCall(toolCall))
1857
+ && response.toolCalls.every((toolCall) => engine.isReadOnlyToolCall(
1858
+ toolCall,
1859
+ tools.find((tool) => tool?.name === toolCall?.function?.name) || null,
1860
+ ))
1698
1861
  );
1699
1862
  if (canRunParallelBatch) {
1700
1863
  const parallelToolNames = response.toolCalls
@@ -1718,6 +1881,11 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1718
1881
  iteration,
1719
1882
  options,
1720
1883
  });
1884
+ const lifecycleAfterBatch = await engine.checkpointLifecycle(runId, 'tool_boundary', {
1885
+ iteration,
1886
+ stepIndex: batch.endingStepIndex,
1887
+ });
1888
+ if (lifecycleAfterBatch?.action === 'stop' || lifecycleAfterBatch?.action === 'interrupt') break;
1721
1889
  stepIndex = batch.endingStepIndex;
1722
1890
  let batchGatheredNewEvidence = false;
1723
1891
  for (const item of batch.results) {
@@ -1726,6 +1894,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1726
1894
  item.toolArgs,
1727
1895
  item.result,
1728
1896
  item.error || '',
1897
+ tools.find((tool) => tool?.name === item.toolName) || null,
1729
1898
  );
1730
1899
  execution.input = item.toolArgs;
1731
1900
  execution.artifacts = await extractArtifactsFromResult(item.toolName, item.result);
@@ -1765,7 +1934,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1765
1934
  currentTool: null,
1766
1935
  currentStepStartedAt: null,
1767
1936
  }, {
1768
- verified: true,
1937
+ verified: batchGatheredNewEvidence,
1769
1938
  });
1770
1939
  if (analysis.mode === 'execute' || analysis.mode === 'plan_execute') {
1771
1940
  const iterMeta = engine.getRunMeta(runId);
@@ -1922,6 +2091,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1922
2091
 
1923
2092
  let toolResult;
1924
2093
  let toolErrorMessage = '';
2094
+ let toolInterruptedForPause = false;
1925
2095
  try {
1926
2096
  toolResult = await engine.executeTool(toolName, toolArgs, {
1927
2097
  userId,
@@ -1938,6 +2108,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1938
2108
  deliveryState: options.deliveryState || null,
1939
2109
  allowMultipleProactiveMessages: options.allowMultipleProactiveMessages === true,
1940
2110
  allowExternalSideEffects: options.allowExternalSideEffects === true,
2111
+ signal: engine.getRunMeta(runId)?.abortController?.signal,
1941
2112
  });
1942
2113
  engine.detachProcessFromRun(runId, toolResult?.pid);
1943
2114
  toolErrorMessage = inferToolFailureMessage(toolName, toolResult);
@@ -1973,17 +2144,30 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1973
2144
  );
1974
2145
  }
1975
2146
  } catch (err) {
1976
- toolResult = { error: err.message };
1977
- toolErrorMessage = String(err.message || 'Tool execution failed');
1978
- failedStepCount++;
2147
+ const currentRunMeta = engine.getRunMeta(runId);
2148
+ toolInterruptedForPause = currentRunMeta?.status === 'pausing'
2149
+ && currentRunMeta.abortController?.signal?.aborted === true;
2150
+ toolErrorMessage = toolInterruptedForPause
2151
+ ? 'The tool was interrupted for pause after dispatch; its external outcome is unknown.'
2152
+ : String(err.message || 'Tool execution failed');
2153
+ toolResult = toolInterruptedForPause
2154
+ ? { status: 'outcome_unknown', error: toolErrorMessage }
2155
+ : { error: err.message };
2156
+ if (!toolInterruptedForPause) failedStepCount++;
1979
2157
  engine.detachProcessFromRun(runId, toolResult?.pid);
1980
2158
  db.prepare('UPDATE agent_steps SET status = ?, error = ?, completed_at = datetime(\'now\') WHERE id = ?')
1981
- .run('failed', err.message, stepId);
1982
- engine.emit(userId, 'run:tool_end', { runId, stepId, toolName, error: err.message, status: 'failed' });
1983
- engine.recordRunEvent(userId, runId, 'tool_failed', {
2159
+ .run(toolInterruptedForPause ? 'paused' : 'failed', toolErrorMessage, stepId);
2160
+ engine.emit(userId, 'run:tool_end', {
2161
+ runId,
2162
+ stepId,
2163
+ toolName,
2164
+ error: toolErrorMessage,
2165
+ status: toolInterruptedForPause ? 'paused' : 'failed',
2166
+ });
2167
+ engine.recordRunEvent(userId, runId, toolInterruptedForPause ? 'tool_paused' : 'tool_failed', {
1984
2168
  toolName,
1985
- status: 'failed',
1986
- error: err.message,
2169
+ status: toolInterruptedForPause ? 'paused' : 'failed',
2170
+ error: toolErrorMessage,
1987
2171
  durationMs: Date.now() - stepStartedAt,
1988
2172
  }, { agentId, stepId });
1989
2173
  console.warn(
@@ -1991,11 +2175,26 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1991
2175
  );
1992
2176
  }
1993
2177
 
1994
- const execution = classifyToolExecution(toolName, toolArgs, toolResult, toolErrorMessage);
2178
+ const lifecycleAfterTool = await engine.checkpointLifecycle(runId, 'tool_boundary', {
2179
+ iteration,
2180
+ stepIndex,
2181
+ });
2182
+ if (lifecycleAfterTool?.action === 'stop' || lifecycleAfterTool?.action === 'interrupt') break;
2183
+
2184
+ const execution = classifyToolExecution(
2185
+ toolName,
2186
+ toolArgs,
2187
+ toolResult,
2188
+ toolErrorMessage,
2189
+ tools.find((tool) => tool?.name === toolName) || null,
2190
+ );
1995
2191
  execution.input = toolArgs;
1996
2192
  const repetitionObservation = repetitionGuard?.observe(toolName, toolArgs, toolResult);
1997
- if ((execution.stateChanged && isProgressToolCall(toolName, toolArgs))
1998
- || gatheredNewEvidence(execution, repetitionObservation)) {
2193
+ const toolMadeConcreteProgress = (
2194
+ (execution.stateChanged && isProgressToolCall(toolName, toolArgs))
2195
+ || gatheredNewEvidence(execution, repetitionObservation)
2196
+ );
2197
+ if (toolMadeConcreteProgress) {
1999
2198
  iterationConcreteProgress = true;
2000
2199
  }
2001
2200
  execution.artifacts = await extractArtifactsFromResult(toolName, toolResult);
@@ -2054,7 +2253,13 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2054
2253
  tools = engine.getActiveTools(runId);
2055
2254
  }
2056
2255
 
2057
- if (toolErrorMessage) {
2256
+ if (toolInterruptedForPause) {
2257
+ consecutiveToolFailures = 0;
2258
+ messages.push({
2259
+ role: 'system',
2260
+ content: `The outcome of "${toolName}" is unknown because pause interrupted it after dispatch. Do not repeat the call. First inspect or query the affected state with a safe read-only tool, then continue based on verified evidence.`,
2261
+ });
2262
+ } else if (toolErrorMessage) {
2058
2263
  consecutiveToolFailures += 1;
2059
2264
  const currentRunMeta = engine.getRunMeta(runId);
2060
2265
  trackErrorPattern(toolErrorMessage, currentRunMeta);
@@ -2137,7 +2342,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2137
2342
  currentTool: null,
2138
2343
  currentStepStartedAt: null,
2139
2344
  }, {
2140
- verified: true,
2345
+ verified: toolMadeConcreteProgress,
2141
2346
  stepId,
2142
2347
  });
2143
2348
 
@@ -2182,22 +2387,23 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2182
2387
  if (!engine.activeRuns.has(runId)) break;
2183
2388
  }
2184
2389
 
2390
+ const finalizingRunMeta = engine.getRunMeta(runId);
2391
+ if (finalizingRunMeta) finalizingRunMeta.pauseAvailable = false;
2392
+
2185
2393
  if (engine.isRunStopped(runId)) {
2186
2394
  const stoppedRunMeta = engine.getRunMeta(runId);
2187
- const terminalStatus = stoppedRunMeta?.status === 'interrupted' ? 'interrupted' : 'stopped';
2395
+ const persistedTerminal = db.prepare(
2396
+ 'SELECT status, error FROM agent_runs WHERE id = ?',
2397
+ ).get(runId);
2398
+ const terminalStatus = persistedTerminal?.status === 'interrupted'
2399
+ || stoppedRunMeta?.status === 'interrupted'
2400
+ ? 'interrupted'
2401
+ : 'stopped';
2188
2402
  const stopReason = stoppedRunMeta?.stopReason || null;
2189
- db.prepare(
2190
- `UPDATE agent_runs
2191
- SET status = ?,
2192
- error = COALESCE(?, error),
2193
- updated_at = datetime('now'),
2194
- completed_at = datetime('now')
2195
- WHERE id = ?`
2196
- ).run(terminalStatus, stopReason, runId);
2197
2403
  console.warn(
2198
2404
  `[Run ${shortenRunId(runId)}] ${terminalStatus} trigger=${triggerSource} steps=${stepIndex} tokens=${totalTokens}`
2199
2405
  );
2200
- engine.cleanupSubagentsForRun(runId, { cancelRunning: true });
2406
+ await engine.cleanupSubagentsForRun(runId, { cancelRunning: true });
2201
2407
  engine.stopMessagingProgressSupervisor(runId);
2202
2408
  engine.activeRuns.delete(runId);
2203
2409
  engine.emit(userId, terminalStatus === 'interrupted' ? 'run:interrupted' : 'run:stopped', {
@@ -2248,16 +2454,20 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2248
2454
  if (budgetExhaustedWithoutCompletion) {
2249
2455
  console.warn(`[Run ${shortenRunId(runId)}] max_iteration_wrapup model=${model} iteration=${iteration}/${maxIterations}`);
2250
2456
  try {
2251
- const wrapResponse = await withModelCallTimeout(
2252
- provider.chat(
2457
+ const wrapResponse = await runAbortableModelCall(
2458
+ (signal) => provider.chat(
2253
2459
  sanitizeConversationMessages([
2254
2460
  ...messages,
2255
2461
  { role: 'system', content: buildMaxIterationWrapupPrompt(options?.source || null) },
2256
2462
  ]),
2257
2463
  [],
2258
- { model, reasoningEffort: engine.getReasoningEffort(providerName, options) },
2464
+ {
2465
+ model,
2466
+ reasoningEffort: engine.getReasoningEffort(providerName, options),
2467
+ signal,
2468
+ },
2259
2469
  ),
2260
- options,
2470
+ { ...options, signal: engine.getRunMeta(runId)?.abortController?.signal },
2261
2471
  'Max-iteration wrap-up',
2262
2472
  );
2263
2473
  totalTokens += wrapResponse.usage?.totalTokens || 0;
@@ -2290,8 +2500,8 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2290
2500
  console.warn(`[Run ${shortenRunId(runId)}] blank_reply_recovery model=${model}`);
2291
2501
  let recoveredTokens = 0;
2292
2502
  try {
2293
- const recoveryResponse = await withModelCallTimeout(
2294
- provider.chat(
2503
+ const recoveryResponse = await runAbortableModelCall(
2504
+ (signal) => provider.chat(
2295
2505
  sanitizeConversationMessages([
2296
2506
  ...messages,
2297
2507
  {
@@ -2302,10 +2512,11 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2302
2512
  [],
2303
2513
  {
2304
2514
  model,
2305
- reasoningEffort: engine.getReasoningEffort(providerName, options)
2515
+ reasoningEffort: engine.getReasoningEffort(providerName, options),
2516
+ signal,
2306
2517
  }
2307
2518
  ),
2308
- options,
2519
+ { ...options, signal: engine.getRunMeta(runId)?.abortController?.signal },
2309
2520
  'Blank messaging reply recovery',
2310
2521
  );
2311
2522
  recoveredTokens = recoveryResponse.usage?.totalTokens || 0;
@@ -2318,7 +2529,9 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2318
2529
  // own wrap-up (it summarizes what it tried / where it got blocked from the run
2319
2530
  // evidence) instead of second-guessing it into a generic blob. Only fall back to
2320
2531
  // a deterministic message when the model returned nothing usable.
2321
- const recoveredVisible = Boolean(normalizeOutgoingMessage(lastContent, options?.source || null));
2532
+ const recoveredVisible = Boolean(
2533
+ normalizeOutgoingMessage(lastContent, options?.source || null),
2534
+ );
2322
2535
  if (!recoveredVisible) {
2323
2536
  lastContent = buildDeterministicMessagingFallback({ failedStepCount, stepIndex, toolExecutions });
2324
2537
  }
@@ -2336,6 +2549,22 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2336
2549
  }
2337
2550
  }
2338
2551
 
2552
+ const lifecycleBeforeFinalize = await engine.checkpointLifecycle(runId, 'finalization_boundary', {
2553
+ iteration,
2554
+ stepIndex,
2555
+ });
2556
+ if (
2557
+ engine.isRunStopped(runId)
2558
+ || lifecycleBeforeFinalize?.action === 'stop'
2559
+ || lifecycleBeforeFinalize?.action === 'interrupt'
2560
+ ) {
2561
+ const terminal = db.prepare('SELECT status FROM agent_runs WHERE id = ?').get(runId)?.status || 'stopped';
2562
+ await engine.cleanupSubagentsForRun(runId, { cancelRunning: true });
2563
+ engine.stopMessagingProgressSupervisor(runId);
2564
+ engine.activeRuns.delete(runId);
2565
+ return { runId, content: '', totalTokens, iterations: iteration, status: terminal };
2566
+ }
2567
+
2339
2568
  if (
2340
2569
  !normalizeOutgoingMessage(lastContent, options?.source || null)
2341
2570
  && !messagingSent
@@ -2448,6 +2677,9 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2448
2677
  });
2449
2678
  }
2450
2679
 
2680
+ // Final reply terminality is decided by the AI completion judge / task_complete path.
2681
+ // Do not phrase-match natural-language drafts here.
2682
+
2451
2683
  if (deliverableWorkflow && deliverablePlan) {
2452
2684
  engine.recordRunEvent(userId, runId, 'deliverable_validation_started', {
2453
2685
  type: deliverableWorkflow.selection.type,
@@ -2492,8 +2724,23 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2492
2724
  db.prepare('UPDATE conversations SET total_tokens = total_tokens + ?, updated_at = datetime(\'now\') WHERE id = ?')
2493
2725
  .run(totalTokens, conversationId);
2494
2726
  if (options.skipConversationMaintenance !== true) {
2495
- refreshConversationSummary(conversationId, provider, model, historyWindow).catch((err) => {
2496
- console.error('[AI] Conversation summary refresh failed:', err.message);
2727
+ engine.trackBackgroundTask(
2728
+ (signal) => refreshConversationSummary(
2729
+ conversationId,
2730
+ provider,
2731
+ model,
2732
+ historyWindow,
2733
+ false,
2734
+ { signal },
2735
+ ),
2736
+ {
2737
+ key: `conversation-summary:${conversationId}`,
2738
+ signal: runMeta?.abortController?.signal || null,
2739
+ },
2740
+ ).catch((err) => {
2741
+ if (!isAbortError(err, runMeta?.abortController?.signal) && !engine.shuttingDown) {
2742
+ console.error('[AI] Conversation summary refresh failed:', err.message);
2743
+ }
2497
2744
  });
2498
2745
  }
2499
2746
  }
@@ -2515,6 +2762,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2515
2762
  // Fallback: if this was a messaging-triggered run and no final delivery
2516
2763
  // was already sent in this run, auto-send the final assistant text.
2517
2764
  // Interim progress updates do not suppress this final delivery.
2765
+ await engine.stopMessagingProgressSupervisor(runId);
2518
2766
  if (triggerSource === 'messaging' && options.source && options.chatId) {
2519
2767
  if (engine.shouldSendMessagingFinalFallback(runMeta, lastContent || '', options.source) && !lastFinalDeliveryMessage) {
2520
2768
  await engine.deliverMessagingFinalFallback({
@@ -2528,8 +2776,17 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2528
2776
  }
2529
2777
  }
2530
2778
 
2531
- db.prepare('UPDATE agent_runs SET status = ?, total_tokens = ?, final_response = ?, updated_at = datetime(\'now\'), completed_at = datetime(\'now\') WHERE id = ?')
2532
- .run('completed', totalTokens, finalResponseText || null, runId);
2779
+ const completionWon = engine.completeRun(runId, {
2780
+ totalTokens,
2781
+ finalResponse: finalResponseText || null,
2782
+ });
2783
+ if (!completionWon) {
2784
+ const terminal = db.prepare('SELECT status FROM agent_runs WHERE id = ?').get(runId)?.status || 'stopped';
2785
+ await engine.cleanupSubagentsForRun(runId, { cancelRunning: true });
2786
+ engine.stopMessagingProgressSupervisor(runId);
2787
+ engine.activeRuns.delete(runId);
2788
+ return { runId, content: '', totalTokens, iterations: iteration, status: terminal };
2789
+ }
2533
2790
 
2534
2791
  if (conversationId && options.skipConversationMaintenance !== true) {
2535
2792
  await engine.refreshConversationState({
@@ -2542,7 +2799,12 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2542
2799
  analysis,
2543
2800
  verification,
2544
2801
  historyWindow,
2545
- options: { ...options, userId, agentId },
2802
+ options: {
2803
+ ...options,
2804
+ userId,
2805
+ agentId,
2806
+ signal: engine.getRunMeta(runId)?.abortController?.signal || null,
2807
+ },
2546
2808
  }).catch((err) => {
2547
2809
  console.error('[AI] Conversation working state refresh failed:', err.message);
2548
2810
  });
@@ -2551,7 +2813,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2551
2813
  console.info(
2552
2814
  `[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}`
2553
2815
  );
2554
- engine.cleanupSubagentsForRun(runId, { cancelRunning: true });
2816
+ await engine.cleanupSubagentsForRun(runId, { cancelRunning: true });
2555
2817
  engine.stopMessagingProgressSupervisor(runId);
2556
2818
  engine.activeRuns.delete(runId);
2557
2819
  engine.emit(userId, 'run:complete', {
@@ -2584,12 +2846,16 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2584
2846
  // Fire-and-forget: plugins can use this for self-improvement, memory
2585
2847
  // consolidation, analytics, or other post-run housekeeping.
2586
2848
  if (globalHooks.has('on_loop_end')) {
2587
- globalHooks.run('on_loop_end', {
2588
- userId, runId, agentId, status: 'completed',
2589
- iterations: iteration, totalTokens,
2590
- taskAnalysis: analysis,
2591
- finalContent: finalResponseText,
2592
- }).catch(() => {});
2849
+ engine.trackBackgroundTask(
2850
+ (signal) => globalHooks.run('on_loop_end', {
2851
+ userId, runId, agentId, status: 'completed',
2852
+ iterations: iteration, totalTokens,
2853
+ taskAnalysis: analysis,
2854
+ finalContent: finalResponseText,
2855
+ signal,
2856
+ }),
2857
+ { signal: runMeta?.abortController?.signal || null },
2858
+ ).catch(() => {});
2593
2859
  }
2594
2860
  if (engine.learningManager) {
2595
2861
  try {
@@ -2615,25 +2881,37 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2615
2881
 
2616
2882
  return { runId, content: lastContent, totalTokens, iterations: iteration, status: 'completed' };
2617
2883
  } catch (err) {
2884
+ if (!runRecordCreated) throw err;
2618
2885
  if (engine.isRunStopped(runId)) {
2619
- db.prepare('UPDATE agent_runs SET status = ?, updated_at = datetime(\'now\'), completed_at = datetime(\'now\') WHERE id = ?')
2620
- .run('stopped', runId);
2886
+ const stoppedRunMeta = engine.getRunMeta(runId);
2887
+ const persistedTerminal = db.prepare(
2888
+ 'SELECT status, error FROM agent_runs WHERE id = ?',
2889
+ ).get(runId);
2890
+ const terminalStatus = persistedTerminal?.status === 'interrupted'
2891
+ || stoppedRunMeta?.status === 'interrupted'
2892
+ ? 'interrupted'
2893
+ : 'stopped';
2621
2894
  console.warn(
2622
- `[Run ${shortenRunId(runId)}] stopped trigger=${triggerSource} steps=${stepIndex} tokens=${totalTokens}`
2895
+ `[Run ${shortenRunId(runId)}] ${terminalStatus} trigger=${triggerSource} steps=${stepIndex} tokens=${totalTokens}`
2623
2896
  );
2624
- engine.cleanupSubagentsForRun(runId, { cancelRunning: true });
2897
+ await engine.cleanupSubagentsForRun(runId, { cancelRunning: true });
2625
2898
  engine.stopMessagingProgressSupervisor(runId);
2626
2899
  engine.activeRuns.delete(runId);
2627
- engine.emit(userId, 'run:stopped', { runId, triggerSource });
2628
- engine.recordRunEvent(userId, runId, 'run_stopped', {
2900
+ engine.emit(userId, terminalStatus === 'interrupted' ? 'run:interrupted' : 'run:stopped', {
2901
+ runId,
2902
+ triggerSource,
2903
+ reason: stoppedRunMeta?.stopReason || persistedTerminal?.error || null,
2904
+ });
2905
+ engine.recordRunEvent(userId, terminalStatus === 'interrupted' ? 'run_interrupted' : 'run_stopped', {
2629
2906
  triggerSource,
2630
2907
  totalTokens,
2631
2908
  iterations: iteration,
2632
2909
  }, { agentId });
2633
- return { runId, content: '', totalTokens, iterations: iteration, status: 'stopped' };
2910
+ return { runId, content: '', totalTokens, iterations: iteration, status: terminalStatus };
2634
2911
  }
2635
2912
 
2636
2913
  const runMeta = engine.activeRuns.get(runId);
2914
+ await engine.stopMessagingProgressSupervisor(runId);
2637
2915
 
2638
2916
  const deliverableFailureResponse = err?.deliverableResult?.summary
2639
2917
  || err?.deliverableValidation?.summary
@@ -2657,16 +2935,19 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2657
2935
  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)}`
2658
2936
  }
2659
2937
  ]);
2660
- const modelReply = await withModelCallTimeout(
2661
- provider.chat(failedMessage, [], {
2938
+ const modelReply = await runAbortableModelCall(
2939
+ (signal) => provider.chat(failedMessage, [], {
2662
2940
  model,
2663
- reasoningEffort: engine.getReasoningEffort(providerName, options)
2941
+ reasoningEffort: engine.getReasoningEffort(providerName, options),
2942
+ signal,
2664
2943
  }),
2665
- options,
2944
+ { ...options, signal: runMeta?.abortController?.signal },
2666
2945
  'Messaging failure reply',
2667
2946
  );
2668
2947
  const drafted = sanitizeModelOutput(modelReply.content || '', { model });
2669
- if (normalizeOutgoingMessage(drafted, options?.source || null)) {
2948
+ if (
2949
+ normalizeOutgoingMessage(drafted, options?.source || null)
2950
+ ) {
2670
2951
  messagingFailureContent = drafted.trim();
2671
2952
  }
2672
2953
  } catch {
@@ -2688,7 +2969,11 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2688
2969
  options.source,
2689
2970
  options.chatId,
2690
2971
  messagingFailureContent,
2691
- { runId, agentId },
2972
+ {
2973
+ runId,
2974
+ agentId,
2975
+ signal: runMeta?.abortController?.signal || null,
2976
+ },
2692
2977
  );
2693
2978
  requireSuccessfulMessagingDelivery(deliveryResult, 'Messaging failure delivery');
2694
2979
  sendSucceeded = true;
@@ -2705,20 +2990,25 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2705
2990
  }
2706
2991
  }
2707
2992
 
2708
- db.prepare('UPDATE agent_runs SET status = ?, error = ?, final_response = ?, updated_at = datetime(\'now\') WHERE id = ?')
2709
- .run(
2710
- 'failed',
2711
- err.message,
2712
- sendSucceeded
2713
- ? (messagingFailureContent || null)
2714
- : (deliverableFailureResponse || null),
2715
- runId,
2716
- );
2993
+ const failureWon = engine.failRun(runId, {
2994
+ error: err.message,
2995
+ finalResponse: sendSucceeded
2996
+ ? (messagingFailureContent || null)
2997
+ : (deliverableFailureResponse || null),
2998
+ totalTokens,
2999
+ });
3000
+ if (!failureWon) {
3001
+ await engine.cleanupSubagentsForRun(runId, { cancelRunning: true });
3002
+ engine.stopMessagingProgressSupervisor(runId);
3003
+ engine.activeRuns.delete(runId);
3004
+ const terminal = db.prepare('SELECT status FROM agent_runs WHERE id = ?').get(runId)?.status || 'stopped';
3005
+ return { runId, content: '', totalTokens, iterations: iteration, status: terminal };
3006
+ }
2717
3007
  console.error(
2718
3008
  `[Run ${shortenRunId(runId)}] failed trigger=${triggerSource} steps=${stepIndex} tokens=${totalTokens} error=${summarizeForLog(err.message, 180)}`
2719
3009
  );
2720
3010
 
2721
- engine.cleanupSubagentsForRun(runId, { cancelRunning: true });
3011
+ await engine.cleanupSubagentsForRun(runId, { cancelRunning: true });
2722
3012
  engine.stopMessagingProgressSupervisor(runId);
2723
3013
  engine.activeRuns.delete(runId);
2724
3014
  engine.emit(userId, 'run:error', { runId, error: err.message });
@@ -2728,17 +3018,6 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2728
3018
  iterations: iteration,
2729
3019
  deliverableType: deliverableWorkflow?.selection?.type || null,
2730
3020
  }, { agentId });
2731
- timelineService?.recordRunLifecycle?.({
2732
- userId,
2733
- agentId,
2734
- runId,
2735
- title: runTitle,
2736
- eventKind: 'run_failed',
2737
- status: 'failed',
2738
- triggerSource,
2739
- error: err.message,
2740
- });
2741
-
2742
3021
  if (messagingFailureContent) {
2743
3022
  return {
2744
3023
  runId,
@@ -2751,6 +3030,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2751
3030
 
2752
3031
  throw err;
2753
3032
  } finally {
3033
+ detachExternalAbort?.();
2754
3034
  releaseReservation();
2755
3035
  }
2756
3036
  }