neoagent 3.2.1-beta.1 → 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 (172) hide show
  1. package/extensions/chrome-browser/background.mjs +318 -88
  2. package/extensions/chrome-browser/http.mjs +136 -0
  3. package/extensions/chrome-browser/protocol.mjs +654 -90
  4. package/flutter_app/lib/main_chat.dart +118 -739
  5. package/flutter_app/lib/main_controller.dart +111 -20
  6. package/flutter_app/lib/main_integrations.dart +607 -8
  7. package/flutter_app/lib/main_models.dart +3 -0
  8. package/flutter_app/lib/main_operations.dart +334 -321
  9. package/flutter_app/lib/main_security.dart +266 -112
  10. package/flutter_app/lib/main_settings.dart +4 -3
  11. package/flutter_app/lib/main_shared.dart +14 -11
  12. package/flutter_app/lib/src/backend_client.dart +78 -0
  13. package/flutter_app/lib/src/desktop_companion_actions.dart +185 -31
  14. package/flutter_app/lib/src/desktop_companion_io.dart +319 -86
  15. package/flutter_app/windows/runner/flutter_window.cpp +143 -32
  16. package/landing/index.html +3 -1
  17. package/lib/manager.js +106 -89
  18. package/lib/schema_migrations.js +115 -13
  19. package/package.json +30 -15
  20. package/runtime/paths.js +49 -5
  21. package/server/db/database.js +2 -2
  22. package/server/guest-agent.cli.package.json +13 -0
  23. package/server/guest_agent.js +85 -40
  24. package/server/http/middleware.js +24 -0
  25. package/server/http/routes.js +11 -6
  26. package/server/public/.last_build_id +1 -1
  27. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  28. package/server/public/flutter_bootstrap.js +2 -2
  29. package/server/public/main.dart.js +73083 -72209
  30. package/server/routes/admin.js +1 -1
  31. package/server/routes/android.js +30 -34
  32. package/server/routes/browser.js +23 -15
  33. package/server/routes/desktop.js +18 -1
  34. package/server/routes/integrations.js +107 -1
  35. package/server/routes/memory.js +1 -0
  36. package/server/routes/settings.js +16 -5
  37. package/server/routes/social_reach.js +12 -3
  38. package/server/routes/social_video.js +4 -0
  39. package/server/services/agents/manager.js +1 -1
  40. package/server/services/ai/capabilityHealth.js +62 -96
  41. package/server/services/ai/compaction.js +7 -2
  42. package/server/services/ai/history.js +45 -6
  43. package/server/services/ai/integrated_tools/http_request.js +8 -0
  44. package/server/services/ai/loop/agent_engine_core.js +390 -162
  45. package/server/services/ai/loop/blank_recovery.js +5 -4
  46. package/server/services/ai/loop/callbacks.js +1 -0
  47. package/server/services/ai/loop/completion_judge.js +121 -5
  48. package/server/services/ai/loop/conversation_loop.js +510 -332
  49. package/server/services/ai/loop/messaging_delivery.js +129 -57
  50. package/server/services/ai/loop/model_call_guard.js +91 -0
  51. package/server/services/ai/loop/model_io.js +20 -45
  52. package/server/services/ai/loop/progress_classification.js +2 -0
  53. package/server/services/ai/loop/tool_dispatch.js +19 -8
  54. package/server/services/ai/loopPolicy.js +48 -21
  55. package/server/services/ai/messagingFallback.js +17 -17
  56. package/server/services/ai/model_discovery.js +227 -0
  57. package/server/services/ai/model_failure_cache.js +108 -0
  58. package/server/services/ai/model_identity.js +71 -0
  59. package/server/services/ai/models.js +68 -163
  60. package/server/services/ai/providerRetry.js +17 -59
  61. package/server/services/ai/provider_selector.js +166 -0
  62. package/server/services/ai/providers/anthropic.js +2 -2
  63. package/server/services/ai/providers/claudeCode.js +21 -33
  64. package/server/services/ai/providers/githubCopilot.js +41 -20
  65. package/server/services/ai/providers/google.js +135 -97
  66. package/server/services/ai/providers/grok.js +4 -3
  67. package/server/services/ai/providers/grokOauth.js +19 -27
  68. package/server/services/ai/providers/nvidia.js +10 -5
  69. package/server/services/ai/providers/ollama.js +111 -84
  70. package/server/services/ai/providers/ollama_stream.js +142 -0
  71. package/server/services/ai/providers/openai.js +39 -5
  72. package/server/services/ai/providers/openaiCodex.js +11 -4
  73. package/server/services/ai/providers/openrouter.js +29 -7
  74. package/server/services/ai/providers/provider_error.js +36 -0
  75. package/server/services/ai/settings.js +26 -2
  76. package/server/services/ai/systemPrompt.js +19 -12
  77. package/server/services/ai/taskAnalysis.js +58 -10
  78. package/server/services/ai/terminal_reply.js +18 -0
  79. package/server/services/ai/toolEvidence.js +350 -29
  80. package/server/services/ai/tools.js +190 -111
  81. package/server/services/android/controller.js +770 -237
  82. package/server/services/android/process.js +140 -0
  83. package/server/services/android/sdk_download.js +143 -0
  84. package/server/services/android/uia.js +6 -5
  85. package/server/services/artifacts/store.js +24 -0
  86. package/server/services/browser/controller.js +843 -385
  87. package/server/services/browser/extension/gateway.js +40 -16
  88. package/server/services/browser/extension/protocol.js +15 -1
  89. package/server/services/browser/extension/provider.js +71 -47
  90. package/server/services/browser/extension/registry.js +155 -34
  91. package/server/services/cli/executor.js +62 -9
  92. package/server/services/credentials/bitwarden_cli.js +322 -0
  93. package/server/services/credentials/broker.js +594 -0
  94. package/server/services/desktop/gateway.js +41 -4
  95. package/server/services/desktop/protocol.js +3 -0
  96. package/server/services/desktop/provider.js +39 -42
  97. package/server/services/desktop/registry.js +137 -52
  98. package/server/services/integrations/bitwarden/constants.js +14 -0
  99. package/server/services/integrations/bitwarden/provider.js +197 -0
  100. package/server/services/integrations/bitwarden/snapshot.js +65 -0
  101. package/server/services/integrations/figma/provider.js +78 -12
  102. package/server/services/integrations/github/common.js +11 -6
  103. package/server/services/integrations/github/provider.js +52 -53
  104. package/server/services/integrations/google/provider.js +55 -19
  105. package/server/services/integrations/home_assistant/network.js +17 -20
  106. package/server/services/integrations/home_assistant/provider.js +7 -5
  107. package/server/services/integrations/home_assistant/tools.js +17 -5
  108. package/server/services/integrations/http.js +51 -0
  109. package/server/services/integrations/manager.js +159 -53
  110. package/server/services/integrations/microsoft/provider.js +80 -13
  111. package/server/services/integrations/neoarchive/provider.js +55 -29
  112. package/server/services/integrations/neorecall/client.js +17 -10
  113. package/server/services/integrations/neorecall/provider.js +20 -11
  114. package/server/services/integrations/notion/provider.js +16 -13
  115. package/server/services/integrations/oauth_provider.js +115 -51
  116. package/server/services/integrations/registry.js +2 -0
  117. package/server/services/integrations/slack/provider.js +98 -9
  118. package/server/services/integrations/spotify/provider.js +67 -71
  119. package/server/services/integrations/trello/provider.js +21 -7
  120. package/server/services/integrations/weather/provider.js +18 -12
  121. package/server/services/integrations/whatsapp/provider.js +76 -16
  122. package/server/services/manager.js +110 -1
  123. package/server/services/memory/embedding_index.js +20 -8
  124. package/server/services/memory/embeddings.js +151 -90
  125. package/server/services/memory/ingestion.js +50 -9
  126. package/server/services/memory/ingestion_documents.js +13 -3
  127. package/server/services/memory/manager.js +52 -19
  128. package/server/services/messaging/automation.js +85 -10
  129. package/server/services/messaging/formatting_guides.js +7 -4
  130. package/server/services/messaging/http_platforms.js +33 -13
  131. package/server/services/messaging/inbound_queue.js +78 -24
  132. package/server/services/messaging/inbound_store.js +224 -0
  133. package/server/services/messaging/manager.js +326 -51
  134. package/server/services/messaging/typing_keepalive.js +5 -2
  135. package/server/services/messaging/whatsapp.js +22 -14
  136. package/server/services/network/http.js +210 -0
  137. package/server/services/network/safe_request.js +307 -0
  138. package/server/services/runtime/backends/local-vm.js +227 -67
  139. package/server/services/runtime/docker-vm-manager.js +9 -0
  140. package/server/services/runtime/guest_bootstrap.js +30 -4
  141. package/server/services/runtime/guest_image.js +43 -12
  142. package/server/services/runtime/manager.js +77 -23
  143. package/server/services/runtime/validation.js +7 -6
  144. package/server/services/security/tool_categories.js +6 -0
  145. package/server/services/social_reach/channels/github.js +10 -4
  146. package/server/services/social_reach/channels/reddit.js +4 -4
  147. package/server/services/social_reach/channels/rss.js +2 -2
  148. package/server/services/social_reach/channels/social_video.js +12 -7
  149. package/server/services/social_reach/channels/v2ex.js +21 -8
  150. package/server/services/social_reach/channels/x.js +2 -2
  151. package/server/services/social_reach/channels/xueqiu.js +5 -5
  152. package/server/services/social_reach/service.js +9 -6
  153. package/server/services/social_reach/utils.js +65 -14
  154. package/server/services/social_video/service.js +160 -50
  155. package/server/services/tasks/integration_runtime.js +18 -8
  156. package/server/services/tasks/runtime.js +39 -4
  157. package/server/services/voice/agentBridge.js +17 -4
  158. package/server/services/voice/bufferedLiveRelayAdapter.js +5 -0
  159. package/server/services/voice/liveSession.js +31 -0
  160. package/server/services/voice/message.js +1 -1
  161. package/server/services/voice/openaiSpeech.js +33 -8
  162. package/server/services/voice/providers.js +233 -151
  163. package/server/services/voice/runtime.js +2 -2
  164. package/server/services/voice/runtimeManager.js +118 -20
  165. package/server/services/voice/turnRunner.js +6 -0
  166. package/server/services/wearable/firmware_manifest.js +51 -13
  167. package/server/services/wearable/service.js +1 -0
  168. package/server/utils/abort.js +96 -0
  169. package/server/utils/cloud-security.js +110 -3
  170. package/server/utils/files.js +31 -0
  171. package/server/utils/image_payload.js +95 -0
  172. package/server/utils/retry.js +107 -0
@@ -19,8 +19,10 @@ const {
19
19
  const { summarizeCapabilityHealth } = require('../capabilityHealth');
20
20
  const { shouldAcceptTaskComplete } = require('../completion');
21
21
  const { shortenRunId, summarizeForLog } = require('../logFormat');
22
+ const { getProviderForUser } = require('../provider_selector');
22
23
  const { runConversation } = require('./conversation_loop');
23
24
  const {
25
+ TERMINAL_STATUSES,
24
26
  checkpointRun,
25
27
  closeRun,
26
28
  getRunControl,
@@ -30,6 +32,8 @@ const {
30
32
  const {
31
33
  buildChurnAssessmentPrompt,
32
34
  buildCompletionDecisionPrompt,
35
+ enforceTerminalReplyDecision,
36
+ enforceChurnAssessment,
33
37
  normalizeChurnAssessment,
34
38
  normalizeCompletionDecision,
35
39
  resolveRunGoalContext,
@@ -67,7 +71,7 @@ const {
67
71
  const {
68
72
  requestModelResponse: requestModelResponseImpl,
69
73
  requestStructuredJson: requestStructuredJsonImpl,
70
- withModelCallTimeout,
74
+ runAbortableModelCall,
71
75
  } = require('./model_io');
72
76
  const {
73
77
  publishInterimUpdate: publishInterimUpdateImpl,
@@ -83,6 +87,7 @@ const {
83
87
  clampRunContext,
84
88
  } = require('../messagingFallback');
85
89
  const {
90
+ assessResearchAdequacy,
86
91
  summarizeToolExecutions,
87
92
  } = require('../toolEvidence');
88
93
  const {
@@ -97,6 +102,13 @@ const {
97
102
  normalizeRetrievalPlan,
98
103
  shouldEnhanceRetrieval,
99
104
  } = require('../../memory/retrieval_reasoning');
105
+ const {
106
+ createAbortError,
107
+ createLinkedAbortController,
108
+ isAbortError,
109
+ runWithAbortTimeout,
110
+ throwIfAborted,
111
+ } = require('../../../utils/abort');
100
112
 
101
113
  function buildInitialRunMetadata(options = {}) {
102
114
  const metadata = {};
@@ -106,6 +118,9 @@ function buildInitialRunMetadata(options = {}) {
106
118
  if (options.widgetId != null && String(options.widgetId).trim()) {
107
119
  metadata.widgetId = options.widgetId;
108
120
  }
121
+ if (options.messagingInboundJobId != null && String(options.messagingInboundJobId).trim()) {
122
+ metadata.messagingInboundJobId = String(options.messagingInboundJobId).trim();
123
+ }
109
124
  return metadata;
110
125
  }
111
126
 
@@ -131,99 +146,6 @@ function buildAnalyzeTaskFallback(forceMode, userMessage = '') {
131
146
  };
132
147
  }
133
148
 
134
- async function getProviderForUser(userId, task = '', isSubagent = false, modelOverride = null, providerConfig = {}) {
135
- const { getSupportedModels, createProviderInstance } = require('../models');
136
- const agentId = providerConfig.agentId || null;
137
- const aiSettings = getAiSettings(userId, agentId);
138
- const models = await getSupportedModels(userId, agentId);
139
-
140
- let enabledIds = Array.isArray(aiSettings.enabled_models) ? aiSettings.enabled_models : [];
141
- const defaultChatModel = aiSettings.default_chat_model || 'auto';
142
- const defaultSubagentModel = aiSettings.default_subagent_model || 'auto';
143
- const smarterSelection = aiSettings.smarter_model_selector !== false && aiSettings.smarter_model_selector !== 'false';
144
-
145
- const knownModelIds = new Set(models.map((m) => m.id));
146
- const selectableModels = models.filter((m) => m.available !== false);
147
-
148
- enabledIds = Array.isArray(enabledIds)
149
- ? enabledIds
150
- .map((id) => String(id))
151
- .filter((id) => knownModelIds.has(id))
152
- : [];
153
-
154
- let availableModels = selectableModels.filter((m) => enabledIds.includes(m.id));
155
- if (availableModels.length === 0) {
156
- enabledIds = selectableModels.map((m) => m.id);
157
- availableModels = [...selectableModels];
158
- }
159
-
160
- const fallbackModel = availableModels.length > 0 ? availableModels[0] : selectableModels[0];
161
-
162
- if (!fallbackModel) {
163
- throw new Error('No AI providers are currently available. Open Settings and configure at least one provider.');
164
- }
165
-
166
- let selectedModelDef = fallbackModel;
167
- const userSelectedDefault = isSubagent ? defaultSubagentModel : defaultChatModel;
168
-
169
- if (modelOverride && typeof modelOverride === 'string') {
170
- const requested = models.find((m) => m.id === modelOverride.trim());
171
- if (requested && requested.available !== false && enabledIds.includes(requested.id)) {
172
- selectedModelDef = requested;
173
- return {
174
- provider: createProviderInstance(selectedModelDef.provider, userId, providerConfig),
175
- model: selectedModelDef.id,
176
- providerName: selectedModelDef.provider
177
- };
178
- }
179
- }
180
-
181
- if (userSelectedDefault && userSelectedDefault !== 'auto') {
182
- selectedModelDef = models.find((m) => m.id === userSelectedDefault) || fallbackModel;
183
- } else {
184
- const selectionHint = providerConfig.selectionHint && typeof providerConfig.selectionHint === 'object'
185
- ? providerConfig.selectionHint
186
- : {};
187
- const preferredPurpose = String(selectionHint.purpose || '').trim().toLowerCase();
188
- const highAutonomy = selectionHint.autonomyLevel === 'high' || selectionHint.complexity === 'complex';
189
- const requiredConfidence = String(selectionHint.requiredConfidence || '').trim().toLowerCase();
190
- const costMode = String(selectionHint.costMode || aiSettings.cost_mode || 'balanced_auto').trim().toLowerCase();
191
- const requestedPurpose = ['planning', 'coding', 'general', 'fast'].includes(preferredPurpose)
192
- ? preferredPurpose
193
- : '';
194
- const priceRank = { free: 0, cheap: 1, medium: 2, expensive: 3 };
195
- const chooseForPurpose = (purpose) => {
196
- const candidates = availableModels.filter((model) => model.purpose === purpose);
197
- if (candidates.length === 0) return null;
198
- if (['economy', 'cost_saver', 'lowest_cost'].includes(costMode)) {
199
- return [...candidates].sort((left, right) => (
200
- (priceRank[left.priceTier] ?? 99) - (priceRank[right.priceTier] ?? 99)
201
- ))[0];
202
- }
203
- if (['quality', 'highest_quality'].includes(costMode) || requiredConfidence === 'high') {
204
- return candidates.find((model) => model.priceTier !== 'free' && model.priceTier !== 'cheap') || candidates[0];
205
- }
206
- return candidates[0];
207
- };
208
-
209
- if (smarterSelection && requestedPurpose) {
210
- selectedModelDef = chooseForPurpose(requestedPurpose) || fallbackModel;
211
- } else if (smarterSelection && highAutonomy) {
212
- selectedModelDef = chooseForPurpose('planning') || chooseForPurpose('general') || fallbackModel;
213
- } else if (isSubagent) {
214
- selectedModelDef = chooseForPurpose('fast') || fallbackModel;
215
- } else {
216
- selectedModelDef = chooseForPurpose('general') || fallbackModel;
217
- }
218
- }
219
-
220
- return {
221
- provider: createProviderInstance(selectedModelDef.provider, userId, providerConfig),
222
- model: selectedModelDef.id,
223
- providerName: selectedModelDef.provider
224
- };
225
- }
226
-
227
149
  function estimateTokenValue(value) {
228
150
  if (!value) return 0;
229
151
  if (typeof value === 'string') return Math.ceil(value.length / 4);
@@ -234,6 +156,13 @@ class AgentEngine {
234
156
  constructor(io, services = {}) {
235
157
  this.io = io;
236
158
  this.activeRuns = new Map();
159
+ this.activeRunPromises = new Set();
160
+ this.backgroundTasks = new Set();
161
+ this.backgroundTaskQueues = new Map();
162
+ this.subagentStartupTasks = new Set();
163
+ this.lifecycleAbortController = new AbortController();
164
+ this.shuttingDown = false;
165
+ this.shutdownPromise = null;
237
166
  this.subagents = new Map();
238
167
  this.app = services.app || null;
239
168
  this.browserController = services.browserController || null;
@@ -249,6 +178,107 @@ class AgentEngine {
249
178
  this.messagingDeliveryRetry = services.messagingDeliveryRetry || {};
250
179
  }
251
180
 
181
+ trackBackgroundTask(task, options = {}) {
182
+ if (typeof task !== 'function') {
183
+ throw new TypeError('Background task must be a function.');
184
+ }
185
+ if (this.shuttingDown || this.lifecycleAbortController.signal.aborted) {
186
+ return Promise.reject(createAbortError(
187
+ this.lifecycleAbortController.signal,
188
+ 'Agent engine is shutting down.',
189
+ ));
190
+ }
191
+
192
+ const key = String(options.key || '').trim();
193
+ const previous = key ? this.backgroundTaskQueues.get(key) : null;
194
+ if (previous && options.coalesce === true) return previous;
195
+
196
+ const linked = createLinkedAbortController([
197
+ this.lifecycleAbortController.signal,
198
+ options.signal,
199
+ ]);
200
+ const tracked = Promise.resolve().then(async () => {
201
+ if (previous) {
202
+ try {
203
+ await previous;
204
+ } catch {
205
+ // A failed predecessor must not permanently poison this queue.
206
+ }
207
+ }
208
+ throwIfAborted(linked.signal, 'Background task aborted.');
209
+ return task(linked.signal);
210
+ }).finally(() => {
211
+ linked.cleanup();
212
+ });
213
+
214
+ this.backgroundTasks.add(tracked);
215
+ if (key) this.backgroundTaskQueues.set(key, tracked);
216
+ const cleanup = () => {
217
+ this.backgroundTasks.delete(tracked);
218
+ if (key && this.backgroundTaskQueues.get(key) === tracked) {
219
+ this.backgroundTaskQueues.delete(key);
220
+ }
221
+ };
222
+ tracked.then(cleanup, cleanup);
223
+ return tracked;
224
+ }
225
+
226
+ shutdown(options = {}) {
227
+ if (this.shutdownPromise) return this.shutdownPromise;
228
+
229
+ const reason = String(
230
+ options.reason || 'Server shutting down while agent work was in progress.',
231
+ );
232
+ const timeoutMs = Math.max(100, Number(options.timeoutMs) || 10000);
233
+ this.shuttingDown = true;
234
+ this.lifecycleAbortController.abort(reason);
235
+ this.interruptAllActiveRuns(reason);
236
+
237
+ const childShutdowns = [];
238
+ const childPromises = [];
239
+ for (const record of this.subagents.values()) {
240
+ if (typeof record.engine?.shutdown === 'function') {
241
+ childShutdowns.push(record.engine.shutdown({ reason, timeoutMs }));
242
+ }
243
+ if (record.promise) childPromises.push(record.promise);
244
+ }
245
+ const pending = [
246
+ ...this.activeRunPromises,
247
+ ...this.backgroundTasks,
248
+ ...this.subagentStartupTasks,
249
+ ...childShutdowns,
250
+ ...childPromises,
251
+ ];
252
+
253
+ this.shutdownPromise = (async () => {
254
+ if (pending.length === 0) {
255
+ return { state: 'stopped', timedOut: false, pendingCount: 0 };
256
+ }
257
+
258
+ let timeout = null;
259
+ const timeoutResult = Symbol('agent-engine-shutdown-timeout');
260
+ const result = await Promise.race([
261
+ Promise.allSettled(pending),
262
+ new Promise((resolve) => {
263
+ timeout = setTimeout(() => resolve(timeoutResult), timeoutMs);
264
+ }),
265
+ ]);
266
+ if (timeout) clearTimeout(timeout);
267
+ const timedOut = result === timeoutResult;
268
+ return {
269
+ state: timedOut ? 'timeout' : 'stopped',
270
+ timedOut,
271
+ pendingCount: timedOut
272
+ ? this.activeRunPromises.size
273
+ + this.backgroundTasks.size
274
+ + this.subagentStartupTasks.size
275
+ + Array.from(this.subagents.values()).filter((record) => !record.settled).length
276
+ : 0,
277
+ };
278
+ })();
279
+ return this.shutdownPromise;
280
+ }
281
+
252
282
  async buildSystemPrompt(userId, context = {}) {
253
283
  const { buildSystemPromptSections } = require('../systemPrompt');
254
284
  const { MemoryManager } = require('../../memory/manager');
@@ -287,18 +317,34 @@ class AgentEngine {
287
317
  options = {},
288
318
  returnDetails = false,
289
319
  }) {
290
- const initial = await memoryManager.recallMemory(userId, query, 12, { agentId });
320
+ const signal = options.signal || this.getRunMeta(runId)?.abortController?.signal || null;
321
+ const initial = await memoryManager.recallMemory(userId, query, 12, {
322
+ agentId,
323
+ signal,
324
+ });
291
325
 
292
326
  const pendingChunks = memoryManager.getPendingExtractionChunks?.(userId, agentId, 5) || [];
293
327
  if (pendingChunks.length) {
294
- this.extractPendingChunks(pendingChunks, {
295
- userId,
296
- agentId,
297
- provider,
298
- providerName,
299
- model,
300
- memoryManager,
301
- }).catch((err) => console.warn('[Memory] Background chunk extraction failed:', err.message));
328
+ this.trackBackgroundTask((backgroundSignal) => this.extractPendingChunks(
329
+ pendingChunks,
330
+ {
331
+ userId,
332
+ agentId,
333
+ provider,
334
+ providerName,
335
+ model,
336
+ memoryManager,
337
+ signal: backgroundSignal,
338
+ },
339
+ ), {
340
+ key: `memory-extraction:${userId}:${agentId || 'main'}`,
341
+ coalesce: true,
342
+ signal,
343
+ }).catch((err) => {
344
+ if (!isAbortError(err, signal) && !this.shuttingDown) {
345
+ console.warn('[Memory] Background chunk extraction failed:', err.message);
346
+ }
347
+ });
302
348
  }
303
349
 
304
350
  const decision = shouldEnhanceRetrieval(initial);
@@ -335,7 +381,7 @@ class AgentEngine {
335
381
  normalize: (raw) => normalizeRetrievalPlan(raw, query),
336
382
  fallback: normalizeRetrievalPlan({}, query),
337
383
  reasoningEffort: this.getReasoningEffort(providerName, options),
338
- telemetry: { runId, stepId, userId, agentId },
384
+ telemetry: { runId, stepId, userId, agentId, signal },
339
385
  phase: 'memory_retrieval_plan',
340
386
  });
341
387
  plan = planned.value;
@@ -346,6 +392,7 @@ class AgentEngine {
346
392
  agentId,
347
393
  validAt: plan.validAt,
348
394
  includeHistory: plan.temporalMode === 'historical',
395
+ signal,
349
396
  }));
350
397
  }
351
398
  merged = mergeRetrievalResults(resultSets, 30);
@@ -360,7 +407,7 @@ class AgentEngine {
360
407
  normalize: (raw) => normalizeRerankResult(raw, merged),
361
408
  fallback: merged,
362
409
  reasoningEffort: this.getReasoningEffort(providerName, options),
363
- telemetry: { runId, stepId, userId, agentId },
410
+ telemetry: { runId, stepId, userId, agentId, signal },
364
411
  phase: 'memory_retrieval_rerank',
365
412
  });
366
413
  reranked = rerankResponse.value;
@@ -368,6 +415,7 @@ class AgentEngine {
368
415
  reranked = merged;
369
416
  }
370
417
  } catch (error) {
418
+ if (isAbortError(error, signal)) throw error;
371
419
  console.warn('[Memory] Retrieval enhancement failed:', error.message);
372
420
  plan = null;
373
421
  merged = initial;
@@ -406,10 +454,8 @@ class AgentEngine {
406
454
  providerName,
407
455
  model,
408
456
  memoryManager,
457
+ signal = null,
409
458
  }) {
410
- const ids = chunks.map((c) => c.id);
411
- memoryManager.markChunksExtracted?.(ids, { success: true });
412
-
413
459
  const consolidationSchema = JSON.stringify({
414
460
  memory_candidates: [{
415
461
  memory: 'Concise standalone fact.',
@@ -430,6 +476,7 @@ class AgentEngine {
430
476
 
431
477
  for (const chunk of chunks) {
432
478
  try {
479
+ throwIfAborted(signal, 'Memory extraction aborted.');
433
480
  const result = await this.requestStructuredJson({
434
481
  provider,
435
482
  providerName,
@@ -446,6 +493,7 @@ class AgentEngine {
446
493
  maxTokens: 800,
447
494
  normalize: (raw) => normalizeMemoryCandidates(raw?.memory_candidates || []),
448
495
  fallback: [],
496
+ telemetry: { userId, agentId, signal },
449
497
  phase: 'document_extraction',
450
498
  });
451
499
 
@@ -457,10 +505,13 @@ class AgentEngine {
457
505
  trustLevel: 'external_source',
458
506
  sourceChunkMemoryId: chunk.id,
459
507
  },
508
+ signal,
460
509
  });
461
510
  }
511
+ memoryManager.markChunksExtracted?.([chunk.id], { success: true });
462
512
  } catch (err) {
463
513
  memoryManager.markChunksExtracted?.([chunk.id], { success: false });
514
+ if (isAbortError(err, signal)) throw err;
464
515
  console.warn('[Memory] Document chunk extraction failed:', err.message);
465
516
  }
466
517
  }
@@ -523,6 +574,7 @@ class AgentEngine {
523
574
  ? deliverableResult.artifacts.slice(0, 6)
524
575
  : [],
525
576
  },
577
+ signal: this.getRunMeta(runId)?.abortController?.signal || null,
526
578
  },
527
579
  );
528
580
  } catch (error) {
@@ -742,6 +794,11 @@ class AgentEngine {
742
794
  }) {
743
795
  const runMeta = options?.runId ? this.getRunMeta(options.runId) : null;
744
796
  const goalContext = resolveRunGoalContext(runMeta, analysis, plan);
797
+ const researchAdequacy = assessResearchAdequacy({
798
+ analysis,
799
+ goalContext,
800
+ toolExecutions,
801
+ });
745
802
  const response = await this.requestStructuredJson({
746
803
  provider,
747
804
  providerName,
@@ -757,6 +814,8 @@ class AgentEngine {
757
814
  lastReply,
758
815
  iteration,
759
816
  maxIterations,
817
+ analysis,
818
+ researchAdequacy,
760
819
  }),
761
820
  maxTokens: 500,
762
821
  normalize: (raw) => normalizeCompletionDecision(raw, 'continue'),
@@ -766,9 +825,15 @@ class AgentEngine {
766
825
  phase: 'completion_decision',
767
826
  });
768
827
  return {
769
- decision: response.value,
828
+ decision: enforceTerminalReplyDecision(response.value, lastReply, {
829
+ analysis,
830
+ goalContext,
831
+ toolExecutions,
832
+ researchAdequacy,
833
+ }),
770
834
  usage: response.usage,
771
835
  raw: response.raw,
836
+ researchAdequacy,
772
837
  };
773
838
  }
774
839
 
@@ -854,6 +919,12 @@ class AgentEngine {
854
919
  goalContext,
855
920
  toolExecutions,
856
921
  iteration,
922
+ analysis,
923
+ researchAdequacy: assessResearchAdequacy({
924
+ analysis,
925
+ goalContext,
926
+ toolExecutions,
927
+ }),
857
928
  }),
858
929
  maxTokens: 200,
859
930
  normalize: normalizeChurnAssessment,
@@ -862,9 +933,20 @@ class AgentEngine {
862
933
  telemetry: options,
863
934
  phase: 'churn_assessment',
864
935
  });
936
+ const researchAdequacy = assessResearchAdequacy({
937
+ analysis,
938
+ goalContext,
939
+ toolExecutions,
940
+ });
865
941
  return {
866
- assessment: response.value,
942
+ assessment: enforceChurnAssessment(response.value, {
943
+ analysis,
944
+ goalContext,
945
+ toolExecutions,
946
+ researchAdequacy,
947
+ }),
867
948
  usage: response.usage,
949
+ researchAdequacy,
868
950
  };
869
951
  }
870
952
 
@@ -931,11 +1013,12 @@ class AgentEngine {
931
1013
  }
932
1014
  ];
933
1015
 
934
- const response = await withModelCallTimeout(
935
- provider.chat(promptMessages, [], {
1016
+ const response = await runAbortableModelCall(
1017
+ (signal) => provider.chat(promptMessages, [], {
936
1018
  model,
937
1019
  maxTokens: 800,
938
1020
  reasoningEffort: this.getReasoningEffort(providerName, options),
1021
+ signal,
939
1022
  }),
940
1023
  options,
941
1024
  'Conversation state refresh',
@@ -966,6 +1049,7 @@ class AgentEngine {
966
1049
  agentId: options.agentId || null,
967
1050
  conversationId,
968
1051
  runId,
1052
+ signal: options.signal,
969
1053
  },
970
1054
  );
971
1055
  const { invalidateSystemPromptCache } = require('../systemPrompt');
@@ -982,8 +1066,8 @@ class AgentEngine {
982
1066
  return executeToolImpl(this, toolName, args, context);
983
1067
  }
984
1068
 
985
- isReadOnlyToolCall(toolCall) {
986
- return isReadOnlyToolCallImpl(this, toolCall);
1069
+ isReadOnlyToolCall(toolCall, toolDefinition = null) {
1070
+ return isReadOnlyToolCallImpl(toolCall, toolDefinition);
987
1071
  }
988
1072
 
989
1073
  async executeReadOnlyBatch(toolCalls, context = {}) {
@@ -1119,6 +1203,15 @@ class AgentEngine {
1119
1203
 
1120
1204
  await new Promise((resolve) => { runMeta.resumeRun = resolve; });
1121
1205
  runMeta.resumeRun = null;
1206
+ if (runMeta.status === 'stopped' || runMeta.status === 'interrupted') {
1207
+ return { action: runMeta.status === 'interrupted' ? 'interrupt' : 'stop' };
1208
+ }
1209
+ const persistedStatus = db.prepare('SELECT status FROM agent_runs WHERE id = ?').get(runId)?.status;
1210
+ if (persistedStatus !== 'running') {
1211
+ const action = persistedStatus === 'interrupted' ? 'interrupt' : 'stop';
1212
+ runMeta.status = persistedStatus || 'stopped';
1213
+ return { action };
1214
+ }
1122
1215
  runMeta.abortController = new AbortController();
1123
1216
  runMeta.status = 'running';
1124
1217
  this.startMessagingProgressSupervisor(runId);
@@ -1159,6 +1252,8 @@ class AgentEngine {
1159
1252
  messagingSent = false,
1160
1253
  lastReply = '',
1161
1254
  }) {
1255
+ // Voice fast-path is structural only: short reply, no tools/failures.
1256
+ // Content terminality is left to the model completion judge.
1162
1257
  return options.latencyProfile === 'voice'
1163
1258
  && toolExecutions.length === 0
1164
1259
  && failedStepCount === 0
@@ -1282,15 +1377,37 @@ class AgentEngine {
1282
1377
  }
1283
1378
 
1284
1379
  async runWithModel(userId, userMessage, options = {}, _modelOverride = null) {
1285
- return runConversation(this, userId, userMessage, options, _modelOverride);
1286
- }
1287
-
1288
- async _runWithModelInternal(userId, userMessage, options = {}, _modelOverride = null) {
1289
- return runConversation(this, userId, userMessage, options, _modelOverride);
1380
+ if (this.shuttingDown || this.lifecycleAbortController.signal.aborted) {
1381
+ const error = createAbortError(
1382
+ this.lifecycleAbortController.signal,
1383
+ 'Agent engine is shutting down.',
1384
+ );
1385
+ error.code = error.code || 'AGENT_ENGINE_SHUTTING_DOWN';
1386
+ throw error;
1387
+ }
1388
+ const runPromise = runConversation(this, userId, userMessage, options, _modelOverride);
1389
+ this.activeRunPromises.add(runPromise);
1390
+ try {
1391
+ return await runPromise;
1392
+ } finally {
1393
+ this.activeRunPromises.delete(runPromise);
1394
+ }
1290
1395
  }
1291
1396
 
1292
1397
  async spawnSubagent(userId, parentRunId, task, options = {}) {
1293
- const parentRunMeta = this.getRunMeta(parentRunId);
1398
+ if (this.shuttingDown || this.lifecycleAbortController.signal.aborted) {
1399
+ throw createAbortError(this.lifecycleAbortController.signal, 'Agent engine is shutting down.');
1400
+ }
1401
+
1402
+ let parentRunMeta = this.getRunMeta(parentRunId);
1403
+ if (
1404
+ !parentRunMeta
1405
+ || parentRunMeta.userId !== userId
1406
+ || parentRunMeta.status !== 'running'
1407
+ || parentRunMeta.aborted === true
1408
+ ) {
1409
+ return { error: 'The parent run is no longer active, so a sub-agent cannot be started.' };
1410
+ }
1294
1411
  const parentDepth = Math.max(0, Number(parentRunMeta?.subagentDepth) || 0);
1295
1412
  if (parentDepth >= 1) {
1296
1413
  return {
@@ -1300,24 +1417,77 @@ class AgentEngine {
1300
1417
 
1301
1418
  const aiSettings = getAiSettings(userId, options.agentId || null);
1302
1419
  const maxSubagentsPerRun = Math.max(1, Number(aiSettings.subagent_max_children_per_run) || 10);
1303
- const existingSubagents = Array.from(this.subagents.values())
1304
- .filter((record) => record.parentRunId === parentRunId);
1305
- if (existingSubagents.length >= maxSubagentsPerRun) {
1420
+ const countExistingSubagents = () => Array.from(this.subagents.values())
1421
+ .filter((record) => record.parentRunId === parentRunId).length;
1422
+ if (countExistingSubagents() >= maxSubagentsPerRun) {
1306
1423
  return {
1307
- error: `This run has already spawned ${existingSubagents.length} sub-agents. The limit for one run is ${maxSubagentsPerRun}.`,
1424
+ error: `This run has already spawned ${countExistingSubagents()} sub-agents. The limit for one run is ${maxSubagentsPerRun}.`,
1308
1425
  };
1309
1426
  }
1310
1427
 
1311
1428
  const handle = uuidv4();
1312
1429
  const childRunId = uuidv4();
1313
1430
  let relevantMemories = [];
1431
+ const startupLink = createLinkedAbortController([
1432
+ this.lifecycleAbortController.signal,
1433
+ parentRunMeta.abortController?.signal,
1434
+ options.signal,
1435
+ ]);
1436
+ let memoryRecall = null;
1314
1437
  try {
1315
- relevantMemories = this.memoryManager
1316
- ? await this.memoryManager.recallMemory(userId, task, 4, {
1317
- agentId: options.agentId || null,
1318
- })
1319
- : [];
1320
- } catch {}
1438
+ throwIfAborted(startupLink.signal, 'Sub-agent startup was aborted.');
1439
+ memoryRecall = this.memoryManager
1440
+ ? runWithAbortTimeout(
1441
+ (signal) => this.memoryManager.recallMemory(userId, task, 4, {
1442
+ agentId: options.agentId || null,
1443
+ signal,
1444
+ }),
1445
+ {
1446
+ label: 'Sub-agent memory recall',
1447
+ signal: startupLink.signal,
1448
+ },
1449
+ )
1450
+ : Promise.resolve([]);
1451
+ this.subagentStartupTasks.add(memoryRecall);
1452
+ relevantMemories = await memoryRecall;
1453
+ throwIfAborted(startupLink.signal, 'Sub-agent startup was aborted.');
1454
+ } catch (error) {
1455
+ if (isAbortError(error, startupLink.signal)) throw error;
1456
+ console.warn('[AgentEngine] Sub-agent memory recall failed:', error?.message || error);
1457
+ } finally {
1458
+ if (memoryRecall) this.subagentStartupTasks.delete(memoryRecall);
1459
+ startupLink.cleanup();
1460
+ }
1461
+
1462
+ parentRunMeta = this.getRunMeta(parentRunId);
1463
+ if (
1464
+ this.shuttingDown
1465
+ || this.lifecycleAbortController.signal.aborted
1466
+ || !parentRunMeta
1467
+ || parentRunMeta.userId !== userId
1468
+ || parentRunMeta.status !== 'running'
1469
+ || parentRunMeta.aborted === true
1470
+ ) {
1471
+ throw createAbortError(
1472
+ this.lifecycleAbortController.signal.aborted
1473
+ ? this.lifecycleAbortController.signal
1474
+ : parentRunMeta?.abortController?.signal,
1475
+ 'The parent run stopped before the sub-agent could start.',
1476
+ );
1477
+ }
1478
+ const currentSubagentCount = countExistingSubagents();
1479
+ if (currentSubagentCount >= maxSubagentsPerRun) {
1480
+ return {
1481
+ error: `This run has already spawned ${currentSubagentCount} sub-agents. The limit for one run is ${maxSubagentsPerRun}.`,
1482
+ };
1483
+ }
1484
+
1485
+ const childLink = createLinkedAbortController([
1486
+ this.lifecycleAbortController.signal,
1487
+ parentRunMeta.abortController?.signal,
1488
+ options.signal,
1489
+ ]);
1490
+ throwIfAborted(childLink.signal, 'Sub-agent startup was aborted.');
1321
1491
  const subEngine = new AgentEngine(this.io, {
1322
1492
  app: options.app || this.app,
1323
1493
  browserController: this.browserController,
@@ -1363,6 +1533,8 @@ class AgentEngine {
1363
1533
  error: null,
1364
1534
  engine: subEngine,
1365
1535
  promise: null,
1536
+ settled: false,
1537
+ deleteWhenSettled: false,
1366
1538
  };
1367
1539
  this.subagents.set(handle, record);
1368
1540
  this.emit(userId, 'run:subagent', {
@@ -1386,9 +1558,11 @@ class AgentEngine {
1386
1558
  agentId: options.agentId || null,
1387
1559
  subagentDepth: parentDepth + 1,
1388
1560
  disallowedToolNames: ['spawn_subagent'],
1561
+ signal: childLink.signal,
1389
1562
  },
1390
1563
  options.model || null
1391
1564
  );
1565
+ if (record.status === 'cancelled' || childLink.signal.aborted) return record;
1392
1566
  record.status = result.status || 'completed';
1393
1567
  let structured = null;
1394
1568
  try {
@@ -1416,16 +1590,23 @@ class AgentEngine {
1416
1590
  });
1417
1591
  return record;
1418
1592
  } catch (err) {
1419
- record.status = 'failed';
1420
- record.error = err.message;
1593
+ const cancelled = record.status === 'cancelled' || isAbortError(err, childLink.signal);
1594
+ record.status = cancelled ? 'cancelled' : 'failed';
1595
+ record.error = cancelled ? null : (err?.message || String(err));
1421
1596
  this.emit(userId, 'run:subagent', {
1422
1597
  runId: parentRunId,
1423
1598
  handle,
1424
1599
  childRunId,
1425
- status: 'failed',
1426
- error: err.message,
1600
+ status: record.status,
1601
+ error: record.error,
1427
1602
  });
1428
- throw err;
1603
+ return record;
1604
+ } finally {
1605
+ record.settled = true;
1606
+ childLink.cleanup();
1607
+ if (record.deleteWhenSettled && this.subagents.get(handle) === record) {
1608
+ this.subagents.delete(handle);
1609
+ }
1429
1610
  }
1430
1611
  })();
1431
1612
 
@@ -1562,21 +1743,43 @@ class AgentEngine {
1562
1743
  }));
1563
1744
  }
1564
1745
 
1565
- cleanupSubagentsForRun(parentRunId, options = {}) {
1566
- if (!parentRunId) return;
1746
+ async cleanupSubagentsForRun(parentRunId, options = {}) {
1747
+ if (!parentRunId) return { cancelled: 0, timedOut: 0 };
1567
1748
  const cancelRunning = options.cancelRunning !== false;
1568
- for (const [handle, record] of this.subagents.entries()) {
1749
+ const shutdowns = [];
1750
+ const records = [];
1751
+ for (const [handle, record] of Array.from(this.subagents.entries())) {
1569
1752
  if (record.parentRunId !== parentRunId) continue;
1753
+ records.push([handle, record]);
1754
+ record.deleteWhenSettled = true;
1570
1755
  if (cancelRunning && record.status === 'running') {
1571
- try {
1572
- record.engine?.abort(record.childRunId);
1573
- record.status = 'cancelled';
1574
- } catch (err) {
1575
- console.warn(`[AgentEngine] Failed to abort subagent ${handle}:`, err?.message);
1576
- }
1756
+ record.status = 'cancelled';
1757
+ this.emit(record.userId, 'run:subagent', {
1758
+ runId: record.parentRunId,
1759
+ handle,
1760
+ childRunId: record.childRunId,
1761
+ status: 'cancelled',
1762
+ });
1763
+ shutdowns.push(Promise.resolve(record.engine?.shutdown?.({
1764
+ reason: 'Parent run finished before the sub-agent completed.',
1765
+ timeoutMs: Math.max(100, Number(options.timeoutMs) || 10000),
1766
+ })));
1767
+ } else if (record.status === 'running') {
1768
+ record.deleteWhenSettled = false;
1769
+ }
1770
+ }
1771
+ const results = await Promise.allSettled(shutdowns);
1772
+ for (const [handle, record] of records) {
1773
+ if ((record.settled || !record.promise) && this.subagents.get(handle) === record) {
1774
+ this.subagents.delete(handle);
1577
1775
  }
1578
- this.subagents.delete(handle);
1579
1776
  }
1777
+ return {
1778
+ cancelled: shutdowns.length,
1779
+ timedOut: results.filter((result) => (
1780
+ result.status === 'fulfilled' && result.value?.timedOut === true
1781
+ )).length,
1782
+ };
1580
1783
  }
1581
1784
 
1582
1785
  async waitForSubagent(handle, options = {}) {
@@ -1639,7 +1842,6 @@ class AgentEngine {
1639
1842
  };
1640
1843
  }
1641
1844
 
1642
- record.engine?.abort(record.childRunId);
1643
1845
  record.status = 'cancelled';
1644
1846
  this.emit(record.userId, 'run:subagent', {
1645
1847
  runId: record.parentRunId,
@@ -1648,7 +1850,15 @@ class AgentEngine {
1648
1850
  status: 'cancelled',
1649
1851
  });
1650
1852
 
1651
- return { handle, status: 'cancelled' };
1853
+ const shutdown = await record.engine?.shutdown?.({
1854
+ reason: 'Sub-agent cancelled by its parent run.',
1855
+ timeoutMs: Math.max(100, Number(options.timeoutMs) || 10000),
1856
+ });
1857
+ return {
1858
+ handle,
1859
+ status: 'cancelled',
1860
+ timedOut: shutdown?.timedOut === true,
1861
+ };
1652
1862
  }
1653
1863
 
1654
1864
  interruptRun(runId, reason = 'Server shutting down while run was in progress.') {
@@ -1665,6 +1875,7 @@ class AgentEngine {
1665
1875
  runMeta.stopReason = reason;
1666
1876
  runMeta.aborted = true;
1667
1877
  runMeta.abortController?.abort(reason);
1878
+ runMeta.resumeRun?.();
1668
1879
  this.emit(runMeta.userId, 'run:stopping', { runId });
1669
1880
  for (const pid of runMeta.toolPids) {
1670
1881
  if (this.runtimeManager && typeof this.runtimeManager.killCommand === 'function') {
@@ -1695,20 +1906,26 @@ class AgentEngine {
1695
1906
  }
1696
1907
  }
1697
1908
 
1698
- stopRun(runId) {
1909
+ stopRun(runId, reason = 'Stopped by request.') {
1910
+ const stopReason = String(reason || 'Stopped by request.').slice(0, 1000);
1699
1911
  const runMeta = this.activeRuns.get(runId);
1700
- const persistedRun = runMeta || db.prepare('SELECT user_id AS userId FROM agent_runs WHERE id = ?').get(runId);
1912
+ const persistedRun = runMeta || db.prepare(
1913
+ 'SELECT user_id AS userId, status FROM agent_runs WHERE id = ?',
1914
+ ).get(runId);
1915
+ if (!persistedRun || TERMINAL_STATUSES.has(persistedRun.status)) return false;
1701
1916
  if (persistedRun?.userId != null) {
1702
- requestRunControl(runId, persistedRun.userId, 'stop', 'Stopped by request.');
1917
+ requestRunControl(runId, persistedRun.userId, 'stop', stopReason);
1703
1918
  }
1704
1919
  const delegatedChildren = db.prepare(
1705
1920
  "SELECT child_run_id FROM agent_delegations WHERE parent_run_id = ? AND status = 'running'"
1706
1921
  ).all(runId);
1707
1922
  if (runMeta) {
1708
1923
  runMeta.status = 'stopped';
1924
+ runMeta.stopReason = stopReason;
1709
1925
  runMeta.aborted = true;
1710
- runMeta.abortController?.abort('Run stopped.');
1711
- this.emit(runMeta.userId, 'run:stopping', { runId });
1926
+ runMeta.abortController?.abort(stopReason);
1927
+ runMeta.resumeRun?.();
1928
+ this.emit(runMeta.userId, 'run:stopping', { runId, reason: stopReason });
1712
1929
  for (const pid of runMeta.toolPids) {
1713
1930
  if (this.runtimeManager && typeof this.runtimeManager.killCommand === 'function') {
1714
1931
  void this.runtimeManager.killCommand(runMeta.userId, pid, 'aborted');
@@ -1718,13 +1935,22 @@ class AgentEngine {
1718
1935
  }
1719
1936
  for (const child of delegatedChildren) {
1720
1937
  if (child.child_run_id && child.child_run_id !== runId) {
1721
- this.stopRun(child.child_run_id);
1938
+ this.stopRun(child.child_run_id, stopReason);
1722
1939
  }
1723
1940
  }
1724
1941
  db.prepare(
1725
- "UPDATE agent_delegations SET status = 'stopped', updated_at = datetime('now'), completed_at = datetime('now') WHERE parent_run_id = ? AND status = 'running'"
1726
- ).run(runId);
1727
- closeRun(runId, 'stopped', {}, ['running', 'pausing', 'paused', 'resuming']);
1942
+ `UPDATE agent_delegations
1943
+ SET status = 'stopped', error = COALESCE(NULLIF(error, ''), ?),
1944
+ updated_at = datetime('now'), completed_at = datetime('now')
1945
+ WHERE parent_run_id = ? AND status = 'running'`
1946
+ ).run(stopReason, runId);
1947
+ closeRun(
1948
+ runId,
1949
+ 'stopped',
1950
+ { error: stopReason },
1951
+ ['running', 'pausing', 'paused', 'resuming'],
1952
+ );
1953
+ return true;
1728
1954
  }
1729
1955
 
1730
1956
  pauseRun(runId, { userId, reason = '' } = {}) {
@@ -1766,15 +1992,17 @@ class AgentEngine {
1766
1992
  return true;
1767
1993
  }
1768
1994
 
1769
- abort(runId, { userId } = {}) {
1995
+ abort(runId, { userId, reason = 'Stopped by request.' } = {}) {
1770
1996
  if (!runId) return false;
1997
+ const runMeta = this.activeRuns.get(runId);
1998
+ const persistedRun = runMeta
1999
+ || db.prepare('SELECT user_id AS userId, status FROM agent_runs WHERE id = ?').get(runId);
2000
+ if (!persistedRun || TERMINAL_STATUSES.has(persistedRun.status)) return false;
1771
2001
  if (userId != null) {
1772
- // Ownership gate: never let one user abort another user's active run.
1773
- const runMeta = this.activeRuns.get(runId);
1774
- if (runMeta && Number(runMeta.userId) !== Number(userId)) return false;
2002
+ // Ownership gate applies to both active and persisted runs.
2003
+ if (Number(persistedRun.userId) !== Number(userId)) return false;
1775
2004
  }
1776
- this.stopRun(runId);
1777
- return true;
2005
+ return this.stopRun(runId, reason);
1778
2006
  }
1779
2007
 
1780
2008
  abortAll(userId) {