neoagent 3.2.1-beta.1 → 3.2.1-beta.3

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 (144) 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 +511 -89
  4. package/flutter_app/lib/main_chat.dart +118 -739
  5. package/flutter_app/lib/main_controller.dart +29 -20
  6. package/flutter_app/lib/main_models.dart +3 -0
  7. package/flutter_app/lib/main_operations.dart +334 -321
  8. package/flutter_app/lib/main_settings.dart +4 -3
  9. package/flutter_app/lib/main_shared.dart +14 -11
  10. package/flutter_app/lib/src/desktop_companion_actions.dart +185 -31
  11. package/flutter_app/lib/src/desktop_companion_io.dart +319 -86
  12. package/flutter_app/windows/runner/flutter_window.cpp +143 -32
  13. package/lib/manager.js +106 -89
  14. package/lib/schema_migrations.js +67 -13
  15. package/package.json +20 -13
  16. package/runtime/paths.js +49 -5
  17. package/server/guest_agent.js +52 -30
  18. package/server/http/middleware.js +24 -0
  19. package/server/http/routes.js +4 -6
  20. package/server/public/.last_build_id +1 -1
  21. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  22. package/server/public/flutter_bootstrap.js +1 -1
  23. package/server/public/main.dart.js +30803 -30805
  24. package/server/routes/admin.js +1 -1
  25. package/server/routes/android.js +30 -34
  26. package/server/routes/browser.js +23 -15
  27. package/server/routes/desktop.js +18 -1
  28. package/server/routes/integrations.js +5 -1
  29. package/server/routes/memory.js +1 -0
  30. package/server/routes/settings.js +16 -5
  31. package/server/routes/social_reach.js +12 -3
  32. package/server/routes/social_video.js +4 -0
  33. package/server/services/ai/compaction.js +7 -2
  34. package/server/services/ai/history.js +44 -5
  35. package/server/services/ai/integrated_tools/http_request.js +8 -0
  36. package/server/services/ai/loop/agent_engine_core.js +358 -162
  37. package/server/services/ai/loop/callbacks.js +1 -0
  38. package/server/services/ai/loop/completion_judge.js +12 -0
  39. package/server/services/ai/loop/conversation_loop.js +348 -242
  40. package/server/services/ai/loop/messaging_delivery.js +129 -57
  41. package/server/services/ai/loop/model_call_guard.js +91 -0
  42. package/server/services/ai/loop/model_io.js +20 -45
  43. package/server/services/ai/loop/tool_dispatch.js +19 -8
  44. package/server/services/ai/loopPolicy.js +24 -19
  45. package/server/services/ai/model_discovery.js +227 -0
  46. package/server/services/ai/model_identity.js +71 -0
  47. package/server/services/ai/models.js +67 -162
  48. package/server/services/ai/providerRetry.js +17 -59
  49. package/server/services/ai/provider_selector.js +111 -0
  50. package/server/services/ai/providers/anthropic.js +2 -2
  51. package/server/services/ai/providers/claudeCode.js +21 -33
  52. package/server/services/ai/providers/githubCopilot.js +41 -20
  53. package/server/services/ai/providers/google.js +23 -5
  54. package/server/services/ai/providers/grok.js +4 -3
  55. package/server/services/ai/providers/grokOauth.js +19 -27
  56. package/server/services/ai/providers/nvidia.js +10 -5
  57. package/server/services/ai/providers/ollama.js +111 -84
  58. package/server/services/ai/providers/ollama_stream.js +142 -0
  59. package/server/services/ai/providers/openai.js +39 -5
  60. package/server/services/ai/providers/openaiCodex.js +11 -4
  61. package/server/services/ai/providers/openrouter.js +29 -7
  62. package/server/services/ai/providers/provider_error.js +36 -0
  63. package/server/services/ai/settings.js +26 -2
  64. package/server/services/ai/taskAnalysis.js +5 -1
  65. package/server/services/ai/terminal_reply.js +45 -0
  66. package/server/services/ai/toolEvidence.js +58 -29
  67. package/server/services/ai/tools.js +124 -111
  68. package/server/services/android/controller.js +770 -237
  69. package/server/services/android/process.js +140 -0
  70. package/server/services/android/sdk_download.js +143 -0
  71. package/server/services/android/uia.js +6 -5
  72. package/server/services/artifacts/store.js +24 -0
  73. package/server/services/browser/controller.js +736 -385
  74. package/server/services/browser/extension/gateway.js +40 -16
  75. package/server/services/browser/extension/protocol.js +12 -1
  76. package/server/services/browser/extension/provider.js +59 -47
  77. package/server/services/browser/extension/registry.js +155 -34
  78. package/server/services/cli/executor.js +62 -9
  79. package/server/services/desktop/gateway.js +41 -4
  80. package/server/services/desktop/protocol.js +3 -0
  81. package/server/services/desktop/provider.js +39 -42
  82. package/server/services/desktop/registry.js +137 -52
  83. package/server/services/integrations/figma/provider.js +78 -12
  84. package/server/services/integrations/github/common.js +11 -6
  85. package/server/services/integrations/github/provider.js +52 -53
  86. package/server/services/integrations/google/provider.js +55 -19
  87. package/server/services/integrations/home_assistant/network.js +17 -20
  88. package/server/services/integrations/home_assistant/provider.js +7 -5
  89. package/server/services/integrations/home_assistant/tools.js +17 -5
  90. package/server/services/integrations/http.js +51 -0
  91. package/server/services/integrations/manager.js +158 -53
  92. package/server/services/integrations/microsoft/provider.js +80 -13
  93. package/server/services/integrations/neoarchive/provider.js +55 -29
  94. package/server/services/integrations/neorecall/client.js +17 -10
  95. package/server/services/integrations/neorecall/provider.js +20 -11
  96. package/server/services/integrations/notion/provider.js +16 -13
  97. package/server/services/integrations/oauth_provider.js +115 -51
  98. package/server/services/integrations/slack/provider.js +98 -9
  99. package/server/services/integrations/spotify/provider.js +67 -71
  100. package/server/services/integrations/trello/provider.js +21 -7
  101. package/server/services/integrations/weather/provider.js +18 -12
  102. package/server/services/integrations/whatsapp/provider.js +76 -16
  103. package/server/services/manager.js +87 -1
  104. package/server/services/memory/embedding_index.js +20 -8
  105. package/server/services/memory/embeddings.js +151 -90
  106. package/server/services/memory/ingestion.js +50 -9
  107. package/server/services/memory/ingestion_documents.js +13 -3
  108. package/server/services/memory/manager.js +52 -19
  109. package/server/services/messaging/automation.js +84 -9
  110. package/server/services/messaging/http_platforms.js +33 -13
  111. package/server/services/messaging/inbound_queue.js +78 -24
  112. package/server/services/messaging/inbound_store.js +224 -0
  113. package/server/services/messaging/manager.js +326 -51
  114. package/server/services/messaging/typing_keepalive.js +5 -2
  115. package/server/services/network/http.js +210 -0
  116. package/server/services/network/safe_request.js +307 -0
  117. package/server/services/runtime/backends/local-vm.js +214 -66
  118. package/server/services/runtime/manager.js +17 -12
  119. package/server/services/social_reach/channels/github.js +10 -4
  120. package/server/services/social_reach/channels/reddit.js +4 -4
  121. package/server/services/social_reach/channels/rss.js +2 -2
  122. package/server/services/social_reach/channels/social_video.js +12 -7
  123. package/server/services/social_reach/channels/v2ex.js +21 -8
  124. package/server/services/social_reach/channels/x.js +2 -2
  125. package/server/services/social_reach/channels/xueqiu.js +5 -5
  126. package/server/services/social_reach/service.js +9 -6
  127. package/server/services/social_reach/utils.js +65 -14
  128. package/server/services/social_video/service.js +160 -50
  129. package/server/services/tasks/integration_runtime.js +18 -8
  130. package/server/services/tasks/runtime.js +39 -4
  131. package/server/services/voice/agentBridge.js +17 -4
  132. package/server/services/voice/bufferedLiveRelayAdapter.js +5 -0
  133. package/server/services/voice/liveSession.js +31 -0
  134. package/server/services/voice/openaiSpeech.js +33 -8
  135. package/server/services/voice/providers.js +233 -151
  136. package/server/services/voice/runtimeManager.js +118 -20
  137. package/server/services/voice/turnRunner.js +6 -0
  138. package/server/services/wearable/firmware_manifest.js +51 -13
  139. package/server/services/wearable/service.js +1 -0
  140. package/server/utils/abort.js +96 -0
  141. package/server/utils/cloud-security.js +110 -3
  142. package/server/utils/files.js +31 -0
  143. package/server/utils/image_payload.js +95 -0
  144. package/server/utils/retry.js +107 -0
@@ -55,6 +55,11 @@ 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 { getProviderForUser } = require('../provider_selector');
58
63
  const { IterationBudget } = require('./iteration_budget');
59
64
  const {
60
65
  buildBlankAfterToolFailureGuidance,
@@ -116,7 +121,7 @@ const {
116
121
  const {
117
122
  requestModelResponse: requestModelResponseImpl,
118
123
  requestStructuredJson: requestStructuredJsonImpl,
119
- withModelCallTimeout,
124
+ runAbortableModelCall,
120
125
  } = require('./model_io');
121
126
  const {
122
127
  publishInterimUpdate: publishInterimUpdateImpl,
@@ -143,6 +148,7 @@ const {
143
148
  buildDeterministicMessagingErrorReply,
144
149
  buildModelFailureLoopPrompt,
145
150
  } = require('../messagingFallback');
151
+ const { isDeferredWorkReply } = require('../terminal_reply');
146
152
  const {
147
153
  classifyToolExecution,
148
154
  gatheredNewEvidence,
@@ -164,6 +170,7 @@ const {
164
170
  normalizeRetrievalPlan,
165
171
  shouldEnhanceRetrieval,
166
172
  } = require('../../memory/retrieval_reasoning');
173
+ const { isAbortError, throwIfAborted } = require('../../../utils/abort');
167
174
 
168
175
  function generateTitle(task) {
169
176
  if (!task || typeof task !== 'string') return 'Untitled';
@@ -403,145 +410,55 @@ function buildAutonomyPolicyFromAnalysis(analysis = {}) {
403
410
  };
404
411
  }
405
412
 
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) {
413
+ async function getFailureFallbackModelId(
414
+ userId,
415
+ agentId,
416
+ currentModelId,
417
+ preferredFallbackId = null,
418
+ failureError = null,
419
+ signal = null,
420
+ ) {
503
421
  const { getSupportedModels } = require('../models');
504
422
  const aiSettings = getAiSettings(userId, agentId);
505
- const models = await getSupportedModels(userId, agentId);
423
+ const models = await getSupportedModels(userId, agentId, { signal });
506
424
  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))
425
+ const configuredEnabledIds = Array.isArray(aiSettings.enabled_models)
426
+ ? aiSettings.enabled_models.map((id) => String(id).trim()).filter(Boolean)
510
427
  : [];
511
- const pool = enabledIds.length > 0
428
+ const enabledIds = normalizeModelSelections(availableModels, configuredEnabledIds);
429
+ const pool = configuredEnabledIds.length > 0
512
430
  ? availableModels.filter((model) => enabledIds.includes(model.id))
513
431
  : 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;
432
+ const fallbackSearchPool = pool;
433
+ const currentModel = resolveModelSelection(pool, currentModelId)
434
+ || resolveModelSelection(availableModels, currentModelId);
518
435
 
519
436
  // When the failure is a provider-level rate limit, the preferred fallback is
520
437
  // likely on the same provider and will hit the same limit. Skip it and prefer
521
438
  // a fallback from a different provider instead.
522
439
  const isProviderRateLimit = /429|rate.?limit|free-models-per/i.test(String(failureError?.message || ''));
523
440
 
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;
441
+ if (preferredFallbackId && !isProviderRateLimit) {
442
+ const preferred = resolveModelSelection(fallbackSearchPool, preferredFallbackId)
443
+ || resolveModelSelection(availableModels, preferredFallbackId);
444
+ if (preferred && preferred.id !== currentModel?.id) return preferred.id;
528
445
  }
529
446
 
530
447
  if (currentModel?.provider) {
531
448
  const differentProvider = fallbackSearchPool.find((model) =>
532
- model.id !== currentModelId && model.provider !== currentModel.provider);
449
+ model.id !== currentModel.id && model.provider !== currentModel.provider);
533
450
  if (differentProvider) return differentProvider.id;
534
451
  }
535
452
 
536
453
  // If no different-provider model exists, still try the preferred fallback
537
454
  // 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;
455
+ if (preferredFallbackId) {
456
+ const preferred = resolveModelSelection(fallbackSearchPool, preferredFallbackId)
457
+ || resolveModelSelection(availableModels, preferredFallbackId);
458
+ if (preferred && preferred.id !== currentModel?.id) return preferred.id;
542
459
  }
543
460
 
544
- const differentModel = fallbackSearchPool.find((model) => model.id !== currentModelId);
461
+ const differentModel = fallbackSearchPool.find((model) => model.id !== currentModel?.id);
545
462
  return differentModel?.id || null;
546
463
  }
547
464
 
@@ -552,6 +469,7 @@ function estimateTokenValue(value) {
552
469
  }
553
470
 
554
471
  async function runConversation(engine, userId, userMessage, options = {}, _modelOverride = null) {
472
+ throwIfAborted(options.signal, 'Agent run aborted before startup.');
555
473
  const triggerType = options.triggerType || 'user';
556
474
  const { resolveAgentId } = require('../../agents/manager');
557
475
  const agentId = resolveAgentId(userId, options.agentId || options.agent_id || null);
@@ -563,6 +481,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
563
481
  const triggerSource = options.triggerSource || 'web';
564
482
  let provider = null;
565
483
  let model = null;
484
+ let modelSelectionId = null;
566
485
  let providerName = null;
567
486
  let messages = [];
568
487
  let iteration = 0;
@@ -572,7 +491,9 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
572
491
  let failedStepCount = 0;
573
492
  let toolExecutions = [];
574
493
  let deliverableWorkflow = null;
575
- let runTitle;
494
+ let detachExternalAbort = null;
495
+ const runTitle = generateTitle(userMessage);
496
+ let runRecordCreated = false;
576
497
  const timelineService = app?.locals?.timelineService || null;
577
498
 
578
499
  const { releaseReservation } = enforceRateLimits(userId, {
@@ -589,6 +510,59 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
589
510
  // analysis when the mode is known. See the post-analysis policy rebuild below.
590
511
  let loopPolicy = buildLoopPolicy(aiSettings, triggerType, 'execute', options);
591
512
  let maxIterations = loopPolicy.maxIterations;
513
+ const initialRunMetadata = buildInitialRunMetadata(options);
514
+ const requestedModel = String(
515
+ _modelOverride
516
+ || (triggerType === 'subagent' ? aiSettings.default_subagent_model : aiSettings.default_chat_model)
517
+ || 'auto',
518
+ ).trim();
519
+ try {
520
+ db.prepare(`INSERT INTO agent_runs(
521
+ id, user_id, agent_id, title, status, trigger_type, trigger_source, model, metadata_json
522
+ ) VALUES(?, ?, ?, ?, 'running', ?, ?, ?, ?)`).run(
523
+ runId,
524
+ userId,
525
+ agentId,
526
+ runTitle,
527
+ triggerType,
528
+ triggerSource,
529
+ requestedModel,
530
+ Object.keys(initialRunMetadata).length ? JSON.stringify(initialRunMetadata) : null,
531
+ );
532
+ runRecordCreated = true;
533
+ } catch (error) {
534
+ if (/unique|primary key|constraint/i.test(String(error?.message || ''))) {
535
+ const conflict = new Error(`A run with id "${runId}" already exists.`);
536
+ conflict.code = 'RUN_ID_CONFLICT';
537
+ throw conflict;
538
+ }
539
+ throw error;
540
+ }
541
+ const startupAbortController = new AbortController();
542
+ engine.activeRuns.set(runId, {
543
+ userId,
544
+ agentId,
545
+ title: runTitle,
546
+ status: 'running',
547
+ aborted: false,
548
+ abortController: startupAbortController,
549
+ pauseAvailable: false,
550
+ toolPids: new Set(),
551
+ subagentDepth: Math.max(0, Number(options.subagentDepth) || 0),
552
+ });
553
+ if (options.signal) {
554
+ const abortFromExternal = () => {
555
+ engine.interruptRun(
556
+ runId,
557
+ String(options.signal.reason || 'Agent run interrupted by its caller.'),
558
+ );
559
+ };
560
+ if (options.signal.aborted) abortFromExternal();
561
+ else options.signal.addEventListener('abort', abortFromExternal, { once: true });
562
+ detachExternalAbort = () => {
563
+ options.signal.removeEventListener('abort', abortFromExternal);
564
+ };
565
+ }
592
566
  const providerStatusConfig = {
593
567
  agentId,
594
568
  onStatus: (status) => {
@@ -605,18 +579,39 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
605
579
  userMessage,
606
580
  triggerType === 'subagent',
607
581
  _modelOverride,
608
- providerStatusConfig
582
+ { ...providerStatusConfig, signal: startupAbortController.signal }
609
583
  );
610
584
  provider = selectedProvider.provider;
611
585
  model = selectedProvider.model;
586
+ modelSelectionId = selectedProvider.modelSelectionId;
612
587
  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}`);
588
+ if (
589
+ startupAbortController.signal.aborted
590
+ || engine.getRunMeta(runId)?.status !== 'running'
591
+ ) {
592
+ const startupError = new Error(
593
+ String(startupAbortController.signal.reason || 'Run stopped during model selection.'),
594
+ );
595
+ startupError.name = 'AbortError';
596
+ startupError.code = 'ABORT_ERR';
597
+ throw startupError;
598
+ }
599
+ db.prepare('UPDATE agent_runs SET model = ?, updated_at = datetime(\'now\') WHERE id = ?')
600
+ .run(modelSelectionId, runId);
601
+ const switchToFallbackModel = async (failedSelectionId, error, phase) => {
602
+ const fallbackModelId = await getFailureFallbackModelId(
603
+ userId,
604
+ agentId,
605
+ failedSelectionId,
606
+ aiSettings.fallback_model_id,
607
+ error,
608
+ engine.getRunMeta(runId)?.abortController?.signal,
609
+ );
610
+ if (!fallbackModelId || fallbackModelId === failedSelectionId) return false;
611
+ console.log(`[Engine] ${phase} failed on ${failedSelectionId}; attempting fallback to: ${fallbackModelId}`);
617
612
  engine.emit(userId, 'run:interim', {
618
613
  runId,
619
- message: `Model service failed on ${failedModel}; retrying with ${fallbackModelId}.`,
614
+ message: `Model service failed on ${failedSelectionId}; retrying with ${fallbackModelId}.`,
620
615
  phase: 'model_fallback'
621
616
  });
622
617
  const fallback = await getProviderForUser(
@@ -624,46 +619,35 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
624
619
  userMessage,
625
620
  triggerType === 'subagent',
626
621
  fallbackModelId,
627
- providerStatusConfig
622
+ {
623
+ ...providerStatusConfig,
624
+ signal: engine.getRunMeta(runId)?.abortController?.signal,
625
+ }
628
626
  );
629
627
  provider = fallback.provider;
630
628
  model = fallback.model;
629
+ modelSelectionId = fallback.modelSelectionId;
631
630
  providerName = fallback.providerName;
631
+ db.prepare('UPDATE agent_runs SET model = ?, updated_at = datetime(\'now\') WHERE id = ?')
632
+ .run(modelSelectionId, runId);
633
+ Object.assign(engine.getRunMeta(runId) || {}, {
634
+ model,
635
+ modelSelectionId,
636
+ providerName,
637
+ });
632
638
  return true;
633
639
  };
634
640
  const runWithModelFallback = async (phase, fn) => {
635
641
  try {
636
642
  return await fn();
637
643
  } catch (err) {
638
- const failedModel = model;
639
- const switched = await switchToFallbackModel(failedModel, err, phase);
644
+ const failedSelectionId = modelSelectionId;
645
+ const switched = await switchToFallbackModel(failedSelectionId, err, phase);
640
646
  if (!switched) throw err;
641
647
  return await fn();
642
648
  }
643
649
  };
644
650
 
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
651
  const retryMessagingState = options.messagingRetryState || {};
668
652
  const carriedFinalMessage = String(retryMessagingState.lastFinalMessage || '').trim();
669
653
  const carriedExplicitMessageSent = retryMessagingState.explicitMessageSent === true;
@@ -685,6 +669,9 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
685
669
  userId,
686
670
  agentId,
687
671
  title: runTitle,
672
+ model,
673
+ modelSelectionId,
674
+ providerName,
688
675
  status: 'running',
689
676
  aborted: false,
690
677
  messagingSent: false,
@@ -709,7 +696,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
709
696
  voiceSessionId: options.voiceSessionId || null,
710
697
  steeringQueue: [],
711
698
  systemSteeringQueue: [],
712
- abortController: new AbortController(),
699
+ abortController: startupAbortController,
713
700
  pauseAvailable: false,
714
701
  toolPids: new Set(),
715
702
  subagentDepth: Math.max(0, Number(options.subagentDepth) || 0),
@@ -735,10 +722,21 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
735
722
  goalContract: carriedGoalContract,
736
723
  });
737
724
  engine.startMessagingProgressSupervisor(runId);
738
- engine.emit(userId, 'run:start', { runId, agentId, title: runTitle, model, triggerType, triggerSource });
725
+ engine.emit(userId, 'run:start', {
726
+ runId,
727
+ agentId,
728
+ title: runTitle,
729
+ model,
730
+ modelSelectionId,
731
+ provider: providerName,
732
+ triggerType,
733
+ triggerSource,
734
+ });
739
735
  engine.recordRunEvent(userId, runId, 'run_started', {
740
736
  title: runTitle,
741
737
  model,
738
+ modelSelectionId,
739
+ provider: providerName,
742
740
  triggerType,
743
741
  triggerSource,
744
742
  }, { agentId });
@@ -752,7 +750,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
752
750
  triggerSource,
753
751
  });
754
752
  console.info(
755
- `[Run ${shortenRunId(runId)}] started trigger=${triggerSource} type=${triggerType} model=${model} title=${summarizeForLog(runTitle, 120)}`
753
+ `[Run ${shortenRunId(runId)}] started trigger=${triggerSource} type=${triggerType} model=${modelSelectionId} title=${summarizeForLog(runTitle, 120)}`
756
754
  );
757
755
 
758
756
  const systemPrompt = await engine.buildSystemPrompt(userId, {
@@ -883,7 +881,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
883
881
  if (triggerSource === 'messaging') {
884
882
  const runMetaForCompose = engine.getRunMeta(runId);
885
883
  if (runMetaForCompose) {
886
- runMetaForCompose.composeProgressUpdate = async ({ stalled = false } = {}) => {
884
+ runMetaForCompose.composeProgressUpdate = async ({ stalled = false, signal = null } = {}) => {
887
885
  try {
888
886
  const rm = engine.getRunMeta(runId);
889
887
  const ledger = rm?.progressLedger || {};
@@ -900,7 +898,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
900
898
  const priorUpdate = String(rm?.lastInterimMessage || '').trim();
901
899
  const contextBlock = [
902
900
  buildProgressUpdatePrompt(),
903
- stalled ? 'This step has been running a while with no new progress; reassure the user you are still on it.' : '',
901
+ 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
902
  '',
905
903
  `Original request: ${summarizeForLog(userMessage, 320)}`,
906
904
  currentTool ? `Doing now: using ${currentTool}` : '',
@@ -911,16 +909,23 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
911
909
  // formatting guidelines as every other message (single source of truth).
912
910
  const sysContent = [systemPrompt?.stable, systemPrompt?.dynamic].filter(Boolean).join('\n\n')
913
911
  || 'You are a helpful assistant.';
914
- const resp = await withModelCallTimeout(
915
- provider.chat(
912
+ const resp = await runAbortableModelCall(
913
+ (signal) => provider.chat(
916
914
  [
917
915
  { role: 'system', content: sysContent },
918
916
  { role: 'user', content: contextBlock },
919
917
  ],
920
918
  [],
921
- { model, reasoningEffort: engine.getReasoningEffort(providerName, options) },
919
+ {
920
+ model,
921
+ reasoningEffort: engine.getReasoningEffort(providerName, options),
922
+ signal,
923
+ },
922
924
  ),
923
- options,
925
+ {
926
+ ...options,
927
+ signals: [rm?.abortController?.signal, signal],
928
+ },
924
929
  'Progress update compose',
925
930
  );
926
931
  return sanitizeModelOutput(resp.content || '', { model });
@@ -1031,6 +1036,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1031
1036
  null,
1032
1037
  {
1033
1038
  ...providerStatusConfig,
1039
+ signal: engine.getRunMeta(runId)?.abortController?.signal,
1034
1040
  selectionHint: {
1035
1041
  purpose: requestedPurpose,
1036
1042
  complexity: analysis?.complexity,
@@ -1040,15 +1046,21 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1040
1046
  },
1041
1047
  }
1042
1048
  );
1043
- if (selectedAfterAnalysis.model !== model) {
1049
+ if (selectedAfterAnalysis.modelSelectionId !== modelSelectionId) {
1044
1050
  provider = selectedAfterAnalysis.provider;
1045
1051
  model = selectedAfterAnalysis.model;
1052
+ modelSelectionId = selectedAfterAnalysis.modelSelectionId;
1046
1053
  providerName = selectedAfterAnalysis.providerName;
1047
1054
  db.prepare('UPDATE agent_runs SET model = ?, updated_at = datetime(\'now\') WHERE id = ?')
1048
- .run(model, runId);
1055
+ .run(modelSelectionId, runId);
1056
+ Object.assign(engine.getRunMeta(runId) || {}, {
1057
+ model,
1058
+ modelSelectionId,
1059
+ providerName,
1060
+ });
1049
1061
  engine.emit(userId, 'run:interim', {
1050
1062
  runId,
1051
- message: `Switched to ${model} for this run after task analysis.`,
1063
+ message: `Switched to ${modelSelectionId} for this run after task analysis.`,
1052
1064
  phase: 'model_selection'
1053
1065
  });
1054
1066
  }
@@ -1239,7 +1251,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1239
1251
  reason,
1240
1252
  stoppedBy: hookResult.stopped_by || hookResult.stoppedBy || null,
1241
1253
  }, { agentId });
1242
- lastContent = reason;
1254
+ engine.stopRun(runId, reason);
1243
1255
  break;
1244
1256
  }
1245
1257
  const systemSteering = String(hookResult?.systemSteering || hookResult?.system_steering || '').trim();
@@ -1362,8 +1374,8 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1362
1374
  });
1363
1375
  let wrapTokens = 0;
1364
1376
  try {
1365
- const wrapResponse = await withModelCallTimeout(
1366
- provider.chat(
1377
+ const wrapResponse = await runAbortableModelCall(
1378
+ (signal) => provider.chat(
1367
1379
  sanitizeConversationMessages([
1368
1380
  ...messages,
1369
1381
  {
@@ -1376,9 +1388,13 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1376
1388
  },
1377
1389
  ]),
1378
1390
  [],
1379
- { model, reasoningEffort: engine.getReasoningEffort(providerName, options) },
1391
+ {
1392
+ model,
1393
+ reasoningEffort: engine.getReasoningEffort(providerName, options),
1394
+ signal,
1395
+ },
1380
1396
  ),
1381
- options,
1397
+ { ...options, signal: engine.getRunMeta(runId)?.abortController?.signal },
1382
1398
  'No-progress wrap-up',
1383
1399
  );
1384
1400
  wrapTokens = wrapResponse.usage?.totalTokens || 0;
@@ -1387,7 +1403,8 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1387
1403
  console.warn(`[Run ${shortenRunId(runId)}] no_progress_wrapup failed: ${summarizeForLog(wrapErr?.message || wrapErr, 180)}`);
1388
1404
  }
1389
1405
  totalTokens += wrapTokens;
1390
- const usableWrap = normalizeOutgoingMessage(lastContent, options?.source || null);
1406
+ const usableWrap = normalizeOutgoingMessage(lastContent, options?.source || null)
1407
+ && !isDeferredWorkReply(lastContent);
1391
1408
  if (!usableWrap) {
1392
1409
  lastContent = buildDeterministicMessagingFallback({ failedStepCount, stepIndex, toolExecutions });
1393
1410
  }
@@ -1431,9 +1448,9 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1431
1448
  let metrics = engine.estimatePromptMetrics(messages, tools);
1432
1449
  const contextWindow = provider.getContextWindow(model);
1433
1450
  if (metrics.totalEstimatedTokens > contextWindow * loopPolicy.compactionThreshold) {
1434
- messages = await withModelCallTimeout(
1435
- compact(messages, provider, model, contextWindow),
1436
- options,
1451
+ messages = await runAbortableModelCall(
1452
+ (signal) => compact(messages, provider, model, contextWindow, { signal }),
1453
+ { ...options, signal: engine.getRunMeta(runId)?.abortController?.signal },
1437
1454
  `Context compaction before iteration ${iteration}`,
1438
1455
  );
1439
1456
  messages = sanitizeConversationMessages(messages);
@@ -1478,7 +1495,14 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1478
1495
  } catch (err) {
1479
1496
  console.error(`[Engine] Model call failed (${model}):`, err.message);
1480
1497
  const fallbackModelId = retryForFallback
1481
- ? await getFailureFallbackModelId(userId, agentId, model, aiSettings.fallback_model_id, err)
1498
+ ? await getFailureFallbackModelId(
1499
+ userId,
1500
+ agentId,
1501
+ modelSelectionId,
1502
+ aiSettings.fallback_model_id,
1503
+ err,
1504
+ engine.getRunMeta(runId)?.abortController?.signal,
1505
+ )
1482
1506
  : null;
1483
1507
  if (fallbackModelId) {
1484
1508
  const failedModel = model;
@@ -1488,11 +1512,22 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1488
1512
  userMessage,
1489
1513
  triggerType === 'subagent',
1490
1514
  fallbackModelId,
1491
- providerStatusConfig
1515
+ {
1516
+ ...providerStatusConfig,
1517
+ signal: engine.getRunMeta(runId)?.abortController?.signal,
1518
+ }
1492
1519
  );
1493
1520
  provider = fallback.provider;
1494
1521
  model = fallback.model;
1522
+ modelSelectionId = fallback.modelSelectionId;
1495
1523
  providerName = fallback.providerName;
1524
+ db.prepare('UPDATE agent_runs SET model = ?, updated_at = datetime(\'now\') WHERE id = ?')
1525
+ .run(modelSelectionId, runId);
1526
+ Object.assign(engine.getRunMeta(runId) || {}, {
1527
+ model,
1528
+ modelSelectionId,
1529
+ providerName,
1530
+ });
1496
1531
 
1497
1532
  const retryMessages = sanitizeConversationMessages([
1498
1533
  ...messages,
@@ -1512,7 +1547,14 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1512
1547
  model,
1513
1548
  messages: retryMessages,
1514
1549
  tools,
1515
- options: { ...options, userId },
1550
+ options: {
1551
+ ...options,
1552
+ userId,
1553
+ agentId,
1554
+ runId,
1555
+ phase: 'model_turn_fallback',
1556
+ signal: engine.getRunMeta(runId)?.abortController?.signal,
1557
+ },
1516
1558
  runId,
1517
1559
  iteration,
1518
1560
  });
@@ -1547,7 +1589,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1547
1589
 
1548
1590
  if (modelFailureRecoveries < loopPolicy.maxModelFailureRecoveries) {
1549
1591
  const failedModel = model;
1550
- const switched = await switchToFallbackModel(failedModel, err, 'model turn');
1592
+ const switched = await switchToFallbackModel(modelSelectionId, err, 'model turn');
1551
1593
  if (!switched) throw err;
1552
1594
  modelFailureRecoveries += 1;
1553
1595
  failedStepCount += 1;
@@ -1601,10 +1643,6 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1601
1643
  toolCallCount: response.toolCalls?.length || 0,
1602
1644
  contentPreview: String(lastContent || streamContent || '').slice(0, 240),
1603
1645
  }, { agentId });
1604
- engine.updateRunProgress(runId, {}, {
1605
- verified: true,
1606
- });
1607
-
1608
1646
  const assistantMessage = { role: 'assistant', content: lastContent };
1609
1647
  if (response.toolCalls?.length) assistantMessage.tool_calls = response.toolCalls;
1610
1648
  if (response.providerContentBlocks?.length) assistantMessage.providerContentBlocks = response.providerContentBlocks;
@@ -1731,7 +1769,10 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1731
1769
 
1732
1770
  const canRunParallelBatch = (
1733
1771
  response.toolCalls.length > 1
1734
- && response.toolCalls.every((toolCall) => engine.isReadOnlyToolCall(toolCall))
1772
+ && response.toolCalls.every((toolCall) => engine.isReadOnlyToolCall(
1773
+ toolCall,
1774
+ tools.find((tool) => tool?.name === toolCall?.function?.name) || null,
1775
+ ))
1735
1776
  );
1736
1777
  if (canRunParallelBatch) {
1737
1778
  const parallelToolNames = response.toolCalls
@@ -1768,6 +1809,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1768
1809
  item.toolArgs,
1769
1810
  item.result,
1770
1811
  item.error || '',
1812
+ tools.find((tool) => tool?.name === item.toolName) || null,
1771
1813
  );
1772
1814
  execution.input = item.toolArgs;
1773
1815
  execution.artifacts = await extractArtifactsFromResult(item.toolName, item.result);
@@ -1807,7 +1849,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1807
1849
  currentTool: null,
1808
1850
  currentStepStartedAt: null,
1809
1851
  }, {
1810
- verified: true,
1852
+ verified: batchGatheredNewEvidence,
1811
1853
  });
1812
1854
  if (analysis.mode === 'execute' || analysis.mode === 'plan_execute') {
1813
1855
  const iterMeta = engine.getRunMeta(runId);
@@ -2054,11 +2096,20 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2054
2096
  });
2055
2097
  if (lifecycleAfterTool?.action === 'stop' || lifecycleAfterTool?.action === 'interrupt') break;
2056
2098
 
2057
- const execution = classifyToolExecution(toolName, toolArgs, toolResult, toolErrorMessage);
2099
+ const execution = classifyToolExecution(
2100
+ toolName,
2101
+ toolArgs,
2102
+ toolResult,
2103
+ toolErrorMessage,
2104
+ tools.find((tool) => tool?.name === toolName) || null,
2105
+ );
2058
2106
  execution.input = toolArgs;
2059
2107
  const repetitionObservation = repetitionGuard?.observe(toolName, toolArgs, toolResult);
2060
- if ((execution.stateChanged && isProgressToolCall(toolName, toolArgs))
2061
- || gatheredNewEvidence(execution, repetitionObservation)) {
2108
+ const toolMadeConcreteProgress = (
2109
+ (execution.stateChanged && isProgressToolCall(toolName, toolArgs))
2110
+ || gatheredNewEvidence(execution, repetitionObservation)
2111
+ );
2112
+ if (toolMadeConcreteProgress) {
2062
2113
  iterationConcreteProgress = true;
2063
2114
  }
2064
2115
  execution.artifacts = await extractArtifactsFromResult(toolName, toolResult);
@@ -2206,7 +2257,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2206
2257
  currentTool: null,
2207
2258
  currentStepStartedAt: null,
2208
2259
  }, {
2209
- verified: true,
2260
+ verified: toolMadeConcreteProgress,
2210
2261
  stepId,
2211
2262
  });
2212
2263
 
@@ -2256,20 +2307,18 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2256
2307
 
2257
2308
  if (engine.isRunStopped(runId)) {
2258
2309
  const stoppedRunMeta = engine.getRunMeta(runId);
2259
- const terminalStatus = stoppedRunMeta?.status === 'interrupted' ? 'interrupted' : 'stopped';
2310
+ const persistedTerminal = db.prepare(
2311
+ 'SELECT status, error FROM agent_runs WHERE id = ?',
2312
+ ).get(runId);
2313
+ const terminalStatus = persistedTerminal?.status === 'interrupted'
2314
+ || stoppedRunMeta?.status === 'interrupted'
2315
+ ? 'interrupted'
2316
+ : 'stopped';
2260
2317
  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
2318
  console.warn(
2270
2319
  `[Run ${shortenRunId(runId)}] ${terminalStatus} trigger=${triggerSource} steps=${stepIndex} tokens=${totalTokens}`
2271
2320
  );
2272
- engine.cleanupSubagentsForRun(runId, { cancelRunning: true });
2321
+ await engine.cleanupSubagentsForRun(runId, { cancelRunning: true });
2273
2322
  engine.stopMessagingProgressSupervisor(runId);
2274
2323
  engine.activeRuns.delete(runId);
2275
2324
  engine.emit(userId, terminalStatus === 'interrupted' ? 'run:interrupted' : 'run:stopped', {
@@ -2320,16 +2369,20 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2320
2369
  if (budgetExhaustedWithoutCompletion) {
2321
2370
  console.warn(`[Run ${shortenRunId(runId)}] max_iteration_wrapup model=${model} iteration=${iteration}/${maxIterations}`);
2322
2371
  try {
2323
- const wrapResponse = await withModelCallTimeout(
2324
- provider.chat(
2372
+ const wrapResponse = await runAbortableModelCall(
2373
+ (signal) => provider.chat(
2325
2374
  sanitizeConversationMessages([
2326
2375
  ...messages,
2327
2376
  { role: 'system', content: buildMaxIterationWrapupPrompt(options?.source || null) },
2328
2377
  ]),
2329
2378
  [],
2330
- { model, reasoningEffort: engine.getReasoningEffort(providerName, options) },
2379
+ {
2380
+ model,
2381
+ reasoningEffort: engine.getReasoningEffort(providerName, options),
2382
+ signal,
2383
+ },
2331
2384
  ),
2332
- options,
2385
+ { ...options, signal: engine.getRunMeta(runId)?.abortController?.signal },
2333
2386
  'Max-iteration wrap-up',
2334
2387
  );
2335
2388
  totalTokens += wrapResponse.usage?.totalTokens || 0;
@@ -2337,7 +2390,8 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2337
2390
  // On budget exhaustion the model's last text is an untrustworthy mid-thought
2338
2391
  // fragment. Replace it with the wrap-up answer, or a clean deterministic
2339
2392
  // fallback if the wrap-up came back empty — never deliver the fragment.
2340
- const usableWrap = normalizeOutgoingMessage(wrapText, options?.source || null);
2393
+ const usableWrap = normalizeOutgoingMessage(wrapText, options?.source || null)
2394
+ && !isDeferredWorkReply(wrapText);
2341
2395
  lastContent = usableWrap
2342
2396
  ? wrapText
2343
2397
  : buildDeterministicMessagingFallback({ failedStepCount, stepIndex, toolExecutions });
@@ -2362,8 +2416,8 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2362
2416
  console.warn(`[Run ${shortenRunId(runId)}] blank_reply_recovery model=${model}`);
2363
2417
  let recoveredTokens = 0;
2364
2418
  try {
2365
- const recoveryResponse = await withModelCallTimeout(
2366
- provider.chat(
2419
+ const recoveryResponse = await runAbortableModelCall(
2420
+ (signal) => provider.chat(
2367
2421
  sanitizeConversationMessages([
2368
2422
  ...messages,
2369
2423
  {
@@ -2374,10 +2428,11 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2374
2428
  [],
2375
2429
  {
2376
2430
  model,
2377
- reasoningEffort: engine.getReasoningEffort(providerName, options)
2431
+ reasoningEffort: engine.getReasoningEffort(providerName, options),
2432
+ signal,
2378
2433
  }
2379
2434
  ),
2380
- options,
2435
+ { ...options, signal: engine.getRunMeta(runId)?.abortController?.signal },
2381
2436
  'Blank messaging reply recovery',
2382
2437
  );
2383
2438
  recoveredTokens = recoveryResponse.usage?.totalTokens || 0;
@@ -2390,7 +2445,10 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2390
2445
  // own wrap-up (it summarizes what it tried / where it got blocked from the run
2391
2446
  // evidence) instead of second-guessing it into a generic blob. Only fall back to
2392
2447
  // a deterministic message when the model returned nothing usable.
2393
- const recoveredVisible = Boolean(normalizeOutgoingMessage(lastContent, options?.source || null));
2448
+ const recoveredVisible = Boolean(
2449
+ normalizeOutgoingMessage(lastContent, options?.source || null)
2450
+ && !isDeferredWorkReply(lastContent),
2451
+ );
2394
2452
  if (!recoveredVisible) {
2395
2453
  lastContent = buildDeterministicMessagingFallback({ failedStepCount, stepIndex, toolExecutions });
2396
2454
  }
@@ -2418,7 +2476,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2418
2476
  || lifecycleBeforeFinalize?.action === 'interrupt'
2419
2477
  ) {
2420
2478
  const terminal = db.prepare('SELECT status FROM agent_runs WHERE id = ?').get(runId)?.status || 'stopped';
2421
- engine.cleanupSubagentsForRun(runId, { cancelRunning: true });
2479
+ await engine.cleanupSubagentsForRun(runId, { cancelRunning: true });
2422
2480
  engine.stopMessagingProgressSupervisor(runId);
2423
2481
  engine.activeRuns.delete(runId);
2424
2482
  return { runId, content: '', totalTokens, iterations: iteration, status: terminal };
@@ -2536,6 +2594,19 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2536
2594
  });
2537
2595
  }
2538
2596
 
2597
+ if (!messagingSent && isDeferredWorkReply(finalResponseText)) {
2598
+ engine.recordRunEvent(userId, runId, 'non_terminal_final_reply_rejected', {
2599
+ iteration,
2600
+ contentPreview: String(finalResponseText || '').slice(0, 240),
2601
+ }, { agentId });
2602
+ finalResponseText = buildDeterministicMessagingFallback({
2603
+ failedStepCount,
2604
+ stepIndex,
2605
+ toolExecutions,
2606
+ });
2607
+ lastContent = finalResponseText;
2608
+ }
2609
+
2539
2610
  if (deliverableWorkflow && deliverablePlan) {
2540
2611
  engine.recordRunEvent(userId, runId, 'deliverable_validation_started', {
2541
2612
  type: deliverableWorkflow.selection.type,
@@ -2580,8 +2651,23 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2580
2651
  db.prepare('UPDATE conversations SET total_tokens = total_tokens + ?, updated_at = datetime(\'now\') WHERE id = ?')
2581
2652
  .run(totalTokens, conversationId);
2582
2653
  if (options.skipConversationMaintenance !== true) {
2583
- refreshConversationSummary(conversationId, provider, model, historyWindow).catch((err) => {
2584
- console.error('[AI] Conversation summary refresh failed:', err.message);
2654
+ engine.trackBackgroundTask(
2655
+ (signal) => refreshConversationSummary(
2656
+ conversationId,
2657
+ provider,
2658
+ model,
2659
+ historyWindow,
2660
+ false,
2661
+ { signal },
2662
+ ),
2663
+ {
2664
+ key: `conversation-summary:${conversationId}`,
2665
+ signal: runMeta?.abortController?.signal || null,
2666
+ },
2667
+ ).catch((err) => {
2668
+ if (!isAbortError(err, runMeta?.abortController?.signal) && !engine.shuttingDown) {
2669
+ console.error('[AI] Conversation summary refresh failed:', err.message);
2670
+ }
2585
2671
  });
2586
2672
  }
2587
2673
  }
@@ -2603,6 +2689,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2603
2689
  // Fallback: if this was a messaging-triggered run and no final delivery
2604
2690
  // was already sent in this run, auto-send the final assistant text.
2605
2691
  // Interim progress updates do not suppress this final delivery.
2692
+ await engine.stopMessagingProgressSupervisor(runId);
2606
2693
  if (triggerSource === 'messaging' && options.source && options.chatId) {
2607
2694
  if (engine.shouldSendMessagingFinalFallback(runMeta, lastContent || '', options.source) && !lastFinalDeliveryMessage) {
2608
2695
  await engine.deliverMessagingFinalFallback({
@@ -2622,7 +2709,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2622
2709
  });
2623
2710
  if (!completionWon) {
2624
2711
  const terminal = db.prepare('SELECT status FROM agent_runs WHERE id = ?').get(runId)?.status || 'stopped';
2625
- engine.cleanupSubagentsForRun(runId, { cancelRunning: true });
2712
+ await engine.cleanupSubagentsForRun(runId, { cancelRunning: true });
2626
2713
  engine.stopMessagingProgressSupervisor(runId);
2627
2714
  engine.activeRuns.delete(runId);
2628
2715
  return { runId, content: '', totalTokens, iterations: iteration, status: terminal };
@@ -2639,7 +2726,12 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2639
2726
  analysis,
2640
2727
  verification,
2641
2728
  historyWindow,
2642
- options: { ...options, userId, agentId },
2729
+ options: {
2730
+ ...options,
2731
+ userId,
2732
+ agentId,
2733
+ signal: engine.getRunMeta(runId)?.abortController?.signal || null,
2734
+ },
2643
2735
  }).catch((err) => {
2644
2736
  console.error('[AI] Conversation working state refresh failed:', err.message);
2645
2737
  });
@@ -2648,7 +2740,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2648
2740
  console.info(
2649
2741
  `[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
2742
  );
2651
- engine.cleanupSubagentsForRun(runId, { cancelRunning: true });
2743
+ await engine.cleanupSubagentsForRun(runId, { cancelRunning: true });
2652
2744
  engine.stopMessagingProgressSupervisor(runId);
2653
2745
  engine.activeRuns.delete(runId);
2654
2746
  engine.emit(userId, 'run:complete', {
@@ -2681,12 +2773,16 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2681
2773
  // Fire-and-forget: plugins can use this for self-improvement, memory
2682
2774
  // consolidation, analytics, or other post-run housekeeping.
2683
2775
  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(() => {});
2776
+ engine.trackBackgroundTask(
2777
+ (signal) => globalHooks.run('on_loop_end', {
2778
+ userId, runId, agentId, status: 'completed',
2779
+ iterations: iteration, totalTokens,
2780
+ taskAnalysis: analysis,
2781
+ finalContent: finalResponseText,
2782
+ signal,
2783
+ }),
2784
+ { signal: runMeta?.abortController?.signal || null },
2785
+ ).catch(() => {});
2690
2786
  }
2691
2787
  if (engine.learningManager) {
2692
2788
  try {
@@ -2712,25 +2808,37 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2712
2808
 
2713
2809
  return { runId, content: lastContent, totalTokens, iterations: iteration, status: 'completed' };
2714
2810
  } catch (err) {
2811
+ if (!runRecordCreated) throw err;
2715
2812
  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);
2813
+ const stoppedRunMeta = engine.getRunMeta(runId);
2814
+ const persistedTerminal = db.prepare(
2815
+ 'SELECT status, error FROM agent_runs WHERE id = ?',
2816
+ ).get(runId);
2817
+ const terminalStatus = persistedTerminal?.status === 'interrupted'
2818
+ || stoppedRunMeta?.status === 'interrupted'
2819
+ ? 'interrupted'
2820
+ : 'stopped';
2718
2821
  console.warn(
2719
- `[Run ${shortenRunId(runId)}] stopped trigger=${triggerSource} steps=${stepIndex} tokens=${totalTokens}`
2822
+ `[Run ${shortenRunId(runId)}] ${terminalStatus} trigger=${triggerSource} steps=${stepIndex} tokens=${totalTokens}`
2720
2823
  );
2721
- engine.cleanupSubagentsForRun(runId, { cancelRunning: true });
2824
+ await engine.cleanupSubagentsForRun(runId, { cancelRunning: true });
2722
2825
  engine.stopMessagingProgressSupervisor(runId);
2723
2826
  engine.activeRuns.delete(runId);
2724
- engine.emit(userId, 'run:stopped', { runId, triggerSource });
2725
- engine.recordRunEvent(userId, runId, 'run_stopped', {
2827
+ engine.emit(userId, terminalStatus === 'interrupted' ? 'run:interrupted' : 'run:stopped', {
2828
+ runId,
2829
+ triggerSource,
2830
+ reason: stoppedRunMeta?.stopReason || persistedTerminal?.error || null,
2831
+ });
2832
+ engine.recordRunEvent(userId, terminalStatus === 'interrupted' ? 'run_interrupted' : 'run_stopped', {
2726
2833
  triggerSource,
2727
2834
  totalTokens,
2728
2835
  iterations: iteration,
2729
2836
  }, { agentId });
2730
- return { runId, content: '', totalTokens, iterations: iteration, status: 'stopped' };
2837
+ return { runId, content: '', totalTokens, iterations: iteration, status: terminalStatus };
2731
2838
  }
2732
2839
 
2733
2840
  const runMeta = engine.activeRuns.get(runId);
2841
+ await engine.stopMessagingProgressSupervisor(runId);
2734
2842
 
2735
2843
  const deliverableFailureResponse = err?.deliverableResult?.summary
2736
2844
  || err?.deliverableValidation?.summary
@@ -2754,16 +2862,20 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2754
2862
  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
2863
  }
2756
2864
  ]);
2757
- const modelReply = await withModelCallTimeout(
2758
- provider.chat(failedMessage, [], {
2865
+ const modelReply = await runAbortableModelCall(
2866
+ (signal) => provider.chat(failedMessage, [], {
2759
2867
  model,
2760
- reasoningEffort: engine.getReasoningEffort(providerName, options)
2868
+ reasoningEffort: engine.getReasoningEffort(providerName, options),
2869
+ signal,
2761
2870
  }),
2762
- options,
2871
+ { ...options, signal: runMeta?.abortController?.signal },
2763
2872
  'Messaging failure reply',
2764
2873
  );
2765
2874
  const drafted = sanitizeModelOutput(modelReply.content || '', { model });
2766
- if (normalizeOutgoingMessage(drafted, options?.source || null)) {
2875
+ if (
2876
+ normalizeOutgoingMessage(drafted, options?.source || null)
2877
+ && !isDeferredWorkReply(drafted)
2878
+ ) {
2767
2879
  messagingFailureContent = drafted.trim();
2768
2880
  }
2769
2881
  } catch {
@@ -2785,7 +2897,11 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2785
2897
  options.source,
2786
2898
  options.chatId,
2787
2899
  messagingFailureContent,
2788
- { runId, agentId },
2900
+ {
2901
+ runId,
2902
+ agentId,
2903
+ signal: runMeta?.abortController?.signal || null,
2904
+ },
2789
2905
  );
2790
2906
  requireSuccessfulMessagingDelivery(deliveryResult, 'Messaging failure delivery');
2791
2907
  sendSucceeded = true;
@@ -2810,7 +2926,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2810
2926
  totalTokens,
2811
2927
  });
2812
2928
  if (!failureWon) {
2813
- engine.cleanupSubagentsForRun(runId, { cancelRunning: true });
2929
+ await engine.cleanupSubagentsForRun(runId, { cancelRunning: true });
2814
2930
  engine.stopMessagingProgressSupervisor(runId);
2815
2931
  engine.activeRuns.delete(runId);
2816
2932
  const terminal = db.prepare('SELECT status FROM agent_runs WHERE id = ?').get(runId)?.status || 'stopped';
@@ -2820,7 +2936,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2820
2936
  `[Run ${shortenRunId(runId)}] failed trigger=${triggerSource} steps=${stepIndex} tokens=${totalTokens} error=${summarizeForLog(err.message, 180)}`
2821
2937
  );
2822
2938
 
2823
- engine.cleanupSubagentsForRun(runId, { cancelRunning: true });
2939
+ await engine.cleanupSubagentsForRun(runId, { cancelRunning: true });
2824
2940
  engine.stopMessagingProgressSupervisor(runId);
2825
2941
  engine.activeRuns.delete(runId);
2826
2942
  engine.emit(userId, 'run:error', { runId, error: err.message });
@@ -2830,17 +2946,6 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2830
2946
  iterations: iteration,
2831
2947
  deliverableType: deliverableWorkflow?.selection?.type || null,
2832
2948
  }, { 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
2949
  if (messagingFailureContent) {
2845
2950
  return {
2846
2951
  runId,
@@ -2853,6 +2958,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2853
2958
 
2854
2959
  throw err;
2855
2960
  } finally {
2961
+ detachExternalAbort?.();
2856
2962
  releaseReservation();
2857
2963
  }
2858
2964
  }