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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. package/docs/agent-run-lifecycle.md +10 -0
  2. package/extensions/chrome-browser/background.mjs +318 -88
  3. package/extensions/chrome-browser/http.mjs +136 -0
  4. package/extensions/chrome-browser/protocol.mjs +654 -90
  5. package/flutter_app/lib/main_chat.dart +118 -739
  6. package/flutter_app/lib/main_controller.dart +111 -20
  7. package/flutter_app/lib/main_integrations.dart +607 -8
  8. package/flutter_app/lib/main_models.dart +3 -0
  9. package/flutter_app/lib/main_operations.dart +334 -321
  10. package/flutter_app/lib/main_security.dart +266 -112
  11. package/flutter_app/lib/main_settings.dart +4 -3
  12. package/flutter_app/lib/main_shared.dart +14 -11
  13. package/flutter_app/lib/src/backend_client.dart +78 -0
  14. package/flutter_app/lib/src/desktop_companion_actions.dart +185 -31
  15. package/flutter_app/lib/src/desktop_companion_io.dart +319 -86
  16. package/flutter_app/windows/runner/flutter_window.cpp +143 -32
  17. package/landing/index.html +3 -1
  18. package/lib/manager.js +106 -89
  19. package/lib/schema_migrations.js +145 -13
  20. package/package.json +30 -15
  21. package/runtime/paths.js +49 -5
  22. package/server/db/database.js +4 -4
  23. package/server/guest-agent.cli.package.json +13 -0
  24. package/server/guest_agent.js +85 -40
  25. package/server/http/middleware.js +24 -0
  26. package/server/http/routes.js +11 -6
  27. package/server/public/.last_build_id +1 -1
  28. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  29. package/server/public/flutter_bootstrap.js +2 -2
  30. package/server/public/main.dart.js +73083 -72209
  31. package/server/routes/admin.js +1 -1
  32. package/server/routes/agents.js +35 -2
  33. package/server/routes/android.js +30 -34
  34. package/server/routes/browser.js +23 -15
  35. package/server/routes/desktop.js +18 -1
  36. package/server/routes/integrations.js +107 -1
  37. package/server/routes/memory.js +1 -0
  38. package/server/routes/settings.js +16 -5
  39. package/server/routes/social_reach.js +12 -3
  40. package/server/routes/social_video.js +4 -0
  41. package/server/services/agents/manager.js +1 -1
  42. package/server/services/ai/capabilityHealth.js +62 -96
  43. package/server/services/ai/compaction.js +7 -2
  44. package/server/services/ai/history.js +45 -6
  45. package/server/services/ai/integrated_tools/http_request.js +8 -0
  46. package/server/services/ai/loop/agent_engine_core.js +496 -166
  47. package/server/services/ai/loop/blank_recovery.js +5 -4
  48. package/server/services/ai/loop/callbacks.js +1 -0
  49. package/server/services/ai/loop/completion_judge.js +121 -5
  50. package/server/services/ai/loop/conversation_loop.js +620 -340
  51. package/server/services/ai/loop/lifecycle.js +108 -0
  52. package/server/services/ai/loop/messaging_delivery.js +129 -57
  53. package/server/services/ai/loop/model_call_guard.js +91 -0
  54. package/server/services/ai/loop/model_io.js +48 -56
  55. package/server/services/ai/loop/progress_classification.js +2 -0
  56. package/server/services/ai/loop/tool_dispatch.js +28 -8
  57. package/server/services/ai/loopPolicy.js +48 -21
  58. package/server/services/ai/messagingFallback.js +17 -17
  59. package/server/services/ai/model_discovery.js +227 -0
  60. package/server/services/ai/model_failure_cache.js +108 -0
  61. package/server/services/ai/model_identity.js +71 -0
  62. package/server/services/ai/models.js +68 -163
  63. package/server/services/ai/providerRetry.js +17 -59
  64. package/server/services/ai/provider_selector.js +166 -0
  65. package/server/services/ai/providers/anthropic.js +4 -4
  66. package/server/services/ai/providers/claudeCode.js +21 -33
  67. package/server/services/ai/providers/githubCopilot.js +41 -20
  68. package/server/services/ai/providers/google.js +135 -97
  69. package/server/services/ai/providers/grok.js +6 -5
  70. package/server/services/ai/providers/grokOauth.js +19 -27
  71. package/server/services/ai/providers/nvidia.js +12 -7
  72. package/server/services/ai/providers/ollama.js +114 -86
  73. package/server/services/ai/providers/ollama_stream.js +142 -0
  74. package/server/services/ai/providers/openai.js +41 -7
  75. package/server/services/ai/providers/openaiCodex.js +13 -4
  76. package/server/services/ai/providers/openrouter.js +31 -9
  77. package/server/services/ai/providers/provider_error.js +36 -0
  78. package/server/services/ai/settings.js +26 -2
  79. package/server/services/ai/systemPrompt.js +19 -12
  80. package/server/services/ai/taskAnalysis.js +58 -10
  81. package/server/services/ai/terminal_reply.js +18 -0
  82. package/server/services/ai/toolEvidence.js +350 -29
  83. package/server/services/ai/tools.js +190 -111
  84. package/server/services/android/controller.js +770 -237
  85. package/server/services/android/process.js +140 -0
  86. package/server/services/android/sdk_download.js +143 -0
  87. package/server/services/android/uia.js +6 -5
  88. package/server/services/artifacts/store.js +24 -0
  89. package/server/services/browser/controller.js +843 -385
  90. package/server/services/browser/extension/gateway.js +40 -16
  91. package/server/services/browser/extension/protocol.js +15 -1
  92. package/server/services/browser/extension/provider.js +71 -47
  93. package/server/services/browser/extension/registry.js +155 -34
  94. package/server/services/cli/executor.js +62 -9
  95. package/server/services/credentials/bitwarden_cli.js +322 -0
  96. package/server/services/credentials/broker.js +594 -0
  97. package/server/services/desktop/gateway.js +41 -4
  98. package/server/services/desktop/protocol.js +3 -0
  99. package/server/services/desktop/provider.js +39 -42
  100. package/server/services/desktop/registry.js +137 -52
  101. package/server/services/integrations/bitwarden/constants.js +14 -0
  102. package/server/services/integrations/bitwarden/provider.js +197 -0
  103. package/server/services/integrations/bitwarden/snapshot.js +65 -0
  104. package/server/services/integrations/figma/provider.js +78 -12
  105. package/server/services/integrations/github/common.js +11 -6
  106. package/server/services/integrations/github/provider.js +52 -53
  107. package/server/services/integrations/google/provider.js +55 -19
  108. package/server/services/integrations/home_assistant/network.js +17 -20
  109. package/server/services/integrations/home_assistant/provider.js +7 -5
  110. package/server/services/integrations/home_assistant/tools.js +17 -5
  111. package/server/services/integrations/http.js +51 -0
  112. package/server/services/integrations/manager.js +159 -53
  113. package/server/services/integrations/microsoft/provider.js +80 -13
  114. package/server/services/integrations/neoarchive/provider.js +55 -29
  115. package/server/services/integrations/neorecall/client.js +17 -10
  116. package/server/services/integrations/neorecall/provider.js +20 -11
  117. package/server/services/integrations/notion/provider.js +16 -13
  118. package/server/services/integrations/oauth_provider.js +115 -51
  119. package/server/services/integrations/registry.js +2 -0
  120. package/server/services/integrations/slack/provider.js +98 -9
  121. package/server/services/integrations/spotify/provider.js +67 -71
  122. package/server/services/integrations/trello/provider.js +21 -7
  123. package/server/services/integrations/weather/provider.js +18 -12
  124. package/server/services/integrations/whatsapp/provider.js +76 -16
  125. package/server/services/manager.js +110 -1
  126. package/server/services/memory/embedding_index.js +20 -8
  127. package/server/services/memory/embeddings.js +151 -90
  128. package/server/services/memory/ingestion.js +50 -9
  129. package/server/services/memory/ingestion_documents.js +13 -3
  130. package/server/services/memory/manager.js +52 -19
  131. package/server/services/messaging/automation.js +85 -10
  132. package/server/services/messaging/formatting_guides.js +7 -4
  133. package/server/services/messaging/http_platforms.js +33 -13
  134. package/server/services/messaging/inbound_queue.js +78 -24
  135. package/server/services/messaging/inbound_store.js +224 -0
  136. package/server/services/messaging/manager.js +326 -51
  137. package/server/services/messaging/typing_keepalive.js +5 -2
  138. package/server/services/messaging/whatsapp.js +22 -14
  139. package/server/services/network/http.js +210 -0
  140. package/server/services/network/safe_request.js +307 -0
  141. package/server/services/runtime/backends/local-vm.js +227 -67
  142. package/server/services/runtime/docker-vm-manager.js +9 -0
  143. package/server/services/runtime/guest_bootstrap.js +30 -4
  144. package/server/services/runtime/guest_image.js +43 -12
  145. package/server/services/runtime/manager.js +77 -23
  146. package/server/services/runtime/validation.js +7 -6
  147. package/server/services/security/tool_categories.js +6 -0
  148. package/server/services/social_reach/channels/github.js +10 -4
  149. package/server/services/social_reach/channels/reddit.js +4 -4
  150. package/server/services/social_reach/channels/rss.js +2 -2
  151. package/server/services/social_reach/channels/social_video.js +12 -7
  152. package/server/services/social_reach/channels/v2ex.js +21 -8
  153. package/server/services/social_reach/channels/x.js +2 -2
  154. package/server/services/social_reach/channels/xueqiu.js +5 -5
  155. package/server/services/social_reach/service.js +9 -6
  156. package/server/services/social_reach/utils.js +65 -14
  157. package/server/services/social_video/service.js +160 -50
  158. package/server/services/tasks/integration_runtime.js +18 -8
  159. package/server/services/tasks/runtime.js +39 -4
  160. package/server/services/voice/agentBridge.js +17 -4
  161. package/server/services/voice/bufferedLiveRelayAdapter.js +5 -0
  162. package/server/services/voice/liveSession.js +31 -0
  163. package/server/services/voice/message.js +1 -1
  164. package/server/services/voice/openaiSpeech.js +33 -8
  165. package/server/services/voice/providers.js +233 -151
  166. package/server/services/voice/runtime.js +2 -2
  167. package/server/services/voice/runtimeManager.js +118 -20
  168. package/server/services/voice/turnRunner.js +6 -0
  169. package/server/services/wearable/firmware_manifest.js +51 -13
  170. package/server/services/wearable/service.js +1 -0
  171. package/server/utils/abort.js +96 -0
  172. package/server/utils/cloud-security.js +110 -3
  173. package/server/utils/files.js +31 -0
  174. package/server/utils/image_payload.js +95 -0
  175. package/server/utils/retry.js +107 -0
@@ -19,10 +19,21 @@ 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');
24
+ const {
25
+ TERMINAL_STATUSES,
26
+ checkpointRun,
27
+ closeRun,
28
+ getRunControl,
29
+ requestRunControl,
30
+ transitionRun,
31
+ } = require('./lifecycle');
23
32
  const {
24
33
  buildChurnAssessmentPrompt,
25
34
  buildCompletionDecisionPrompt,
35
+ enforceTerminalReplyDecision,
36
+ enforceChurnAssessment,
26
37
  normalizeChurnAssessment,
27
38
  normalizeCompletionDecision,
28
39
  resolveRunGoalContext,
@@ -60,7 +71,7 @@ const {
60
71
  const {
61
72
  requestModelResponse: requestModelResponseImpl,
62
73
  requestStructuredJson: requestStructuredJsonImpl,
63
- withModelCallTimeout,
74
+ runAbortableModelCall,
64
75
  } = require('./model_io');
65
76
  const {
66
77
  publishInterimUpdate: publishInterimUpdateImpl,
@@ -76,6 +87,7 @@ const {
76
87
  clampRunContext,
77
88
  } = require('../messagingFallback');
78
89
  const {
90
+ assessResearchAdequacy,
79
91
  summarizeToolExecutions,
80
92
  } = require('../toolEvidence');
81
93
  const {
@@ -90,6 +102,13 @@ const {
90
102
  normalizeRetrievalPlan,
91
103
  shouldEnhanceRetrieval,
92
104
  } = require('../../memory/retrieval_reasoning');
105
+ const {
106
+ createAbortError,
107
+ createLinkedAbortController,
108
+ isAbortError,
109
+ runWithAbortTimeout,
110
+ throwIfAborted,
111
+ } = require('../../../utils/abort');
93
112
 
94
113
  function buildInitialRunMetadata(options = {}) {
95
114
  const metadata = {};
@@ -99,6 +118,9 @@ function buildInitialRunMetadata(options = {}) {
99
118
  if (options.widgetId != null && String(options.widgetId).trim()) {
100
119
  metadata.widgetId = options.widgetId;
101
120
  }
121
+ if (options.messagingInboundJobId != null && String(options.messagingInboundJobId).trim()) {
122
+ metadata.messagingInboundJobId = String(options.messagingInboundJobId).trim();
123
+ }
102
124
  return metadata;
103
125
  }
104
126
 
@@ -124,99 +146,6 @@ function buildAnalyzeTaskFallback(forceMode, userMessage = '') {
124
146
  };
125
147
  }
126
148
 
127
- async function getProviderForUser(userId, task = '', isSubagent = false, modelOverride = null, providerConfig = {}) {
128
- const { getSupportedModels, createProviderInstance } = require('../models');
129
- const agentId = providerConfig.agentId || null;
130
- const aiSettings = getAiSettings(userId, agentId);
131
- const models = await getSupportedModels(userId, agentId);
132
-
133
- let enabledIds = Array.isArray(aiSettings.enabled_models) ? aiSettings.enabled_models : [];
134
- const defaultChatModel = aiSettings.default_chat_model || 'auto';
135
- const defaultSubagentModel = aiSettings.default_subagent_model || 'auto';
136
- const smarterSelection = aiSettings.smarter_model_selector !== false && aiSettings.smarter_model_selector !== 'false';
137
-
138
- const knownModelIds = new Set(models.map((m) => m.id));
139
- const selectableModels = models.filter((m) => m.available !== false);
140
-
141
- enabledIds = Array.isArray(enabledIds)
142
- ? enabledIds
143
- .map((id) => String(id))
144
- .filter((id) => knownModelIds.has(id))
145
- : [];
146
-
147
- let availableModels = selectableModels.filter((m) => enabledIds.includes(m.id));
148
- if (availableModels.length === 0) {
149
- enabledIds = selectableModels.map((m) => m.id);
150
- availableModels = [...selectableModels];
151
- }
152
-
153
- const fallbackModel = availableModels.length > 0 ? availableModels[0] : selectableModels[0];
154
-
155
- if (!fallbackModel) {
156
- throw new Error('No AI providers are currently available. Open Settings and configure at least one provider.');
157
- }
158
-
159
- let selectedModelDef = fallbackModel;
160
- const userSelectedDefault = isSubagent ? defaultSubagentModel : defaultChatModel;
161
-
162
- if (modelOverride && typeof modelOverride === 'string') {
163
- const requested = models.find((m) => m.id === modelOverride.trim());
164
- if (requested && requested.available !== false && enabledIds.includes(requested.id)) {
165
- selectedModelDef = requested;
166
- return {
167
- provider: createProviderInstance(selectedModelDef.provider, userId, providerConfig),
168
- model: selectedModelDef.id,
169
- providerName: selectedModelDef.provider
170
- };
171
- }
172
- }
173
-
174
- if (userSelectedDefault && userSelectedDefault !== 'auto') {
175
- selectedModelDef = models.find((m) => m.id === userSelectedDefault) || fallbackModel;
176
- } else {
177
- const selectionHint = providerConfig.selectionHint && typeof providerConfig.selectionHint === 'object'
178
- ? providerConfig.selectionHint
179
- : {};
180
- const preferredPurpose = String(selectionHint.purpose || '').trim().toLowerCase();
181
- const highAutonomy = selectionHint.autonomyLevel === 'high' || selectionHint.complexity === 'complex';
182
- const requiredConfidence = String(selectionHint.requiredConfidence || '').trim().toLowerCase();
183
- const costMode = String(selectionHint.costMode || aiSettings.cost_mode || 'balanced_auto').trim().toLowerCase();
184
- const requestedPurpose = ['planning', 'coding', 'general', 'fast'].includes(preferredPurpose)
185
- ? preferredPurpose
186
- : '';
187
- const priceRank = { free: 0, cheap: 1, medium: 2, expensive: 3 };
188
- const chooseForPurpose = (purpose) => {
189
- const candidates = availableModels.filter((model) => model.purpose === purpose);
190
- if (candidates.length === 0) return null;
191
- if (['economy', 'cost_saver', 'lowest_cost'].includes(costMode)) {
192
- return [...candidates].sort((left, right) => (
193
- (priceRank[left.priceTier] ?? 99) - (priceRank[right.priceTier] ?? 99)
194
- ))[0];
195
- }
196
- if (['quality', 'highest_quality'].includes(costMode) || requiredConfidence === 'high') {
197
- return candidates.find((model) => model.priceTier !== 'free' && model.priceTier !== 'cheap') || candidates[0];
198
- }
199
- return candidates[0];
200
- };
201
-
202
- if (smarterSelection && requestedPurpose) {
203
- selectedModelDef = chooseForPurpose(requestedPurpose) || fallbackModel;
204
- } else if (smarterSelection && highAutonomy) {
205
- selectedModelDef = chooseForPurpose('planning') || chooseForPurpose('general') || fallbackModel;
206
- } else if (isSubagent) {
207
- selectedModelDef = chooseForPurpose('fast') || fallbackModel;
208
- } else {
209
- selectedModelDef = chooseForPurpose('general') || fallbackModel;
210
- }
211
- }
212
-
213
- return {
214
- provider: createProviderInstance(selectedModelDef.provider, userId, providerConfig),
215
- model: selectedModelDef.id,
216
- providerName: selectedModelDef.provider
217
- };
218
- }
219
-
220
149
  function estimateTokenValue(value) {
221
150
  if (!value) return 0;
222
151
  if (typeof value === 'string') return Math.ceil(value.length / 4);
@@ -227,6 +156,13 @@ class AgentEngine {
227
156
  constructor(io, services = {}) {
228
157
  this.io = io;
229
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;
230
166
  this.subagents = new Map();
231
167
  this.app = services.app || null;
232
168
  this.browserController = services.browserController || null;
@@ -242,6 +178,107 @@ class AgentEngine {
242
178
  this.messagingDeliveryRetry = services.messagingDeliveryRetry || {};
243
179
  }
244
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
+
245
282
  async buildSystemPrompt(userId, context = {}) {
246
283
  const { buildSystemPromptSections } = require('../systemPrompt');
247
284
  const { MemoryManager } = require('../../memory/manager');
@@ -280,18 +317,34 @@ class AgentEngine {
280
317
  options = {},
281
318
  returnDetails = false,
282
319
  }) {
283
- 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
+ });
284
325
 
285
326
  const pendingChunks = memoryManager.getPendingExtractionChunks?.(userId, agentId, 5) || [];
286
327
  if (pendingChunks.length) {
287
- this.extractPendingChunks(pendingChunks, {
288
- userId,
289
- agentId,
290
- provider,
291
- providerName,
292
- model,
293
- memoryManager,
294
- }).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
+ });
295
348
  }
296
349
 
297
350
  const decision = shouldEnhanceRetrieval(initial);
@@ -328,7 +381,7 @@ class AgentEngine {
328
381
  normalize: (raw) => normalizeRetrievalPlan(raw, query),
329
382
  fallback: normalizeRetrievalPlan({}, query),
330
383
  reasoningEffort: this.getReasoningEffort(providerName, options),
331
- telemetry: { runId, stepId, userId, agentId },
384
+ telemetry: { runId, stepId, userId, agentId, signal },
332
385
  phase: 'memory_retrieval_plan',
333
386
  });
334
387
  plan = planned.value;
@@ -339,6 +392,7 @@ class AgentEngine {
339
392
  agentId,
340
393
  validAt: plan.validAt,
341
394
  includeHistory: plan.temporalMode === 'historical',
395
+ signal,
342
396
  }));
343
397
  }
344
398
  merged = mergeRetrievalResults(resultSets, 30);
@@ -353,7 +407,7 @@ class AgentEngine {
353
407
  normalize: (raw) => normalizeRerankResult(raw, merged),
354
408
  fallback: merged,
355
409
  reasoningEffort: this.getReasoningEffort(providerName, options),
356
- telemetry: { runId, stepId, userId, agentId },
410
+ telemetry: { runId, stepId, userId, agentId, signal },
357
411
  phase: 'memory_retrieval_rerank',
358
412
  });
359
413
  reranked = rerankResponse.value;
@@ -361,6 +415,7 @@ class AgentEngine {
361
415
  reranked = merged;
362
416
  }
363
417
  } catch (error) {
418
+ if (isAbortError(error, signal)) throw error;
364
419
  console.warn('[Memory] Retrieval enhancement failed:', error.message);
365
420
  plan = null;
366
421
  merged = initial;
@@ -399,10 +454,8 @@ class AgentEngine {
399
454
  providerName,
400
455
  model,
401
456
  memoryManager,
457
+ signal = null,
402
458
  }) {
403
- const ids = chunks.map((c) => c.id);
404
- memoryManager.markChunksExtracted?.(ids, { success: true });
405
-
406
459
  const consolidationSchema = JSON.stringify({
407
460
  memory_candidates: [{
408
461
  memory: 'Concise standalone fact.',
@@ -423,6 +476,7 @@ class AgentEngine {
423
476
 
424
477
  for (const chunk of chunks) {
425
478
  try {
479
+ throwIfAborted(signal, 'Memory extraction aborted.');
426
480
  const result = await this.requestStructuredJson({
427
481
  provider,
428
482
  providerName,
@@ -439,6 +493,7 @@ class AgentEngine {
439
493
  maxTokens: 800,
440
494
  normalize: (raw) => normalizeMemoryCandidates(raw?.memory_candidates || []),
441
495
  fallback: [],
496
+ telemetry: { userId, agentId, signal },
442
497
  phase: 'document_extraction',
443
498
  });
444
499
 
@@ -450,10 +505,13 @@ class AgentEngine {
450
505
  trustLevel: 'external_source',
451
506
  sourceChunkMemoryId: chunk.id,
452
507
  },
508
+ signal,
453
509
  });
454
510
  }
511
+ memoryManager.markChunksExtracted?.([chunk.id], { success: true });
455
512
  } catch (err) {
456
513
  memoryManager.markChunksExtracted?.([chunk.id], { success: false });
514
+ if (isAbortError(err, signal)) throw err;
457
515
  console.warn('[Memory] Document chunk extraction failed:', err.message);
458
516
  }
459
517
  }
@@ -516,6 +574,7 @@ class AgentEngine {
516
574
  ? deliverableResult.artifacts.slice(0, 6)
517
575
  : [],
518
576
  },
577
+ signal: this.getRunMeta(runId)?.abortController?.signal || null,
519
578
  },
520
579
  );
521
580
  } catch (error) {
@@ -735,6 +794,11 @@ class AgentEngine {
735
794
  }) {
736
795
  const runMeta = options?.runId ? this.getRunMeta(options.runId) : null;
737
796
  const goalContext = resolveRunGoalContext(runMeta, analysis, plan);
797
+ const researchAdequacy = assessResearchAdequacy({
798
+ analysis,
799
+ goalContext,
800
+ toolExecutions,
801
+ });
738
802
  const response = await this.requestStructuredJson({
739
803
  provider,
740
804
  providerName,
@@ -750,6 +814,8 @@ class AgentEngine {
750
814
  lastReply,
751
815
  iteration,
752
816
  maxIterations,
817
+ analysis,
818
+ researchAdequacy,
753
819
  }),
754
820
  maxTokens: 500,
755
821
  normalize: (raw) => normalizeCompletionDecision(raw, 'continue'),
@@ -759,9 +825,15 @@ class AgentEngine {
759
825
  phase: 'completion_decision',
760
826
  });
761
827
  return {
762
- decision: response.value,
828
+ decision: enforceTerminalReplyDecision(response.value, lastReply, {
829
+ analysis,
830
+ goalContext,
831
+ toolExecutions,
832
+ researchAdequacy,
833
+ }),
763
834
  usage: response.usage,
764
835
  raw: response.raw,
836
+ researchAdequacy,
765
837
  };
766
838
  }
767
839
 
@@ -847,6 +919,12 @@ class AgentEngine {
847
919
  goalContext,
848
920
  toolExecutions,
849
921
  iteration,
922
+ analysis,
923
+ researchAdequacy: assessResearchAdequacy({
924
+ analysis,
925
+ goalContext,
926
+ toolExecutions,
927
+ }),
850
928
  }),
851
929
  maxTokens: 200,
852
930
  normalize: normalizeChurnAssessment,
@@ -855,9 +933,20 @@ class AgentEngine {
855
933
  telemetry: options,
856
934
  phase: 'churn_assessment',
857
935
  });
936
+ const researchAdequacy = assessResearchAdequacy({
937
+ analysis,
938
+ goalContext,
939
+ toolExecutions,
940
+ });
858
941
  return {
859
- assessment: response.value,
942
+ assessment: enforceChurnAssessment(response.value, {
943
+ analysis,
944
+ goalContext,
945
+ toolExecutions,
946
+ researchAdequacy,
947
+ }),
860
948
  usage: response.usage,
949
+ researchAdequacy,
861
950
  };
862
951
  }
863
952
 
@@ -924,11 +1013,12 @@ class AgentEngine {
924
1013
  }
925
1014
  ];
926
1015
 
927
- const response = await withModelCallTimeout(
928
- provider.chat(promptMessages, [], {
1016
+ const response = await runAbortableModelCall(
1017
+ (signal) => provider.chat(promptMessages, [], {
929
1018
  model,
930
1019
  maxTokens: 800,
931
1020
  reasoningEffort: this.getReasoningEffort(providerName, options),
1021
+ signal,
932
1022
  }),
933
1023
  options,
934
1024
  'Conversation state refresh',
@@ -959,6 +1049,7 @@ class AgentEngine {
959
1049
  agentId: options.agentId || null,
960
1050
  conversationId,
961
1051
  runId,
1052
+ signal: options.signal,
962
1053
  },
963
1054
  );
964
1055
  const { invalidateSystemPromptCache } = require('../systemPrompt');
@@ -975,8 +1066,8 @@ class AgentEngine {
975
1066
  return executeToolImpl(this, toolName, args, context);
976
1067
  }
977
1068
 
978
- isReadOnlyToolCall(toolCall) {
979
- return isReadOnlyToolCallImpl(this, toolCall);
1069
+ isReadOnlyToolCall(toolCall, toolDefinition = null) {
1070
+ return isReadOnlyToolCallImpl(toolCall, toolDefinition);
980
1071
  }
981
1072
 
982
1073
  async executeReadOnlyBatch(toolCalls, context = {}) {
@@ -1073,6 +1164,68 @@ class AgentEngine {
1073
1164
  return isRunStoppedImpl(this, runId);
1074
1165
  }
1075
1166
 
1167
+ async checkpointLifecycle(runId, phase, state = {}) {
1168
+ const runMeta = this.activeRuns.get(runId);
1169
+ if (!runMeta) return { action: 'stop' };
1170
+ const control = getRunControl(runId);
1171
+ if (!control) return null;
1172
+ if (control.action !== 'pause') return control;
1173
+
1174
+ checkpointRun(runId, phase, {
1175
+ iteration: Number(state.iteration) || 0,
1176
+ stepIndex: Number(state.stepIndex) || 0,
1177
+ currentPhase: runMeta.progressLedger?.currentPhase || phase,
1178
+ activeTools: (runMeta.activeTools || []).map((tool) => tool.name),
1179
+ goalContract: runMeta.goalContract || null,
1180
+ progressLedger: this.buildProgressLedgerSnapshot(runMeta),
1181
+ });
1182
+ transitionRun(runId, 'paused', {}, ['running', 'pausing']);
1183
+ db.transaction(() => {
1184
+ db.prepare(
1185
+ `UPDATE agent_steps
1186
+ SET status = 'paused', error = COALESCE(NULLIF(error, ''), 'Paused by request.'), completed_at = COALESCE(completed_at, datetime('now'))
1187
+ WHERE run_id = ? AND status = 'running'`,
1188
+ ).run(runId);
1189
+ db.prepare(
1190
+ `UPDATE pending_approvals
1191
+ SET status = 'expired', decided_at = COALESCE(decided_at, datetime('now')), updated_at = datetime('now')
1192
+ WHERE run_id = ? AND status = 'pending'`,
1193
+ ).run(runId);
1194
+ })();
1195
+ runMeta.status = 'paused';
1196
+ runMeta.abortController = null;
1197
+ this.stopMessagingProgressSupervisor(runId);
1198
+ this.emit(runMeta.userId, 'run:paused', { runId, reason: control.reason || null });
1199
+ this.recordRunEvent(runMeta.userId, runId, 'run_paused', {
1200
+ phase,
1201
+ reason: control.reason || null,
1202
+ }, { agentId: runMeta.agentId });
1203
+
1204
+ await new Promise((resolve) => { runMeta.resumeRun = resolve; });
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
+ }
1215
+ runMeta.abortController = new AbortController();
1216
+ runMeta.status = 'running';
1217
+ this.startMessagingProgressSupervisor(runId);
1218
+ return null;
1219
+ }
1220
+
1221
+ completeRun(runId, fields = {}) {
1222
+ return closeRun(runId, 'completed', fields, ['running']);
1223
+ }
1224
+
1225
+ failRun(runId, fields = {}) {
1226
+ return closeRun(runId, 'failed', fields, ['running']);
1227
+ }
1228
+
1076
1229
  attachProcessToRun(runId, pid) {
1077
1230
  return attachProcessToRunImpl(this, runId, pid);
1078
1231
  }
@@ -1099,6 +1252,8 @@ class AgentEngine {
1099
1252
  messagingSent = false,
1100
1253
  lastReply = '',
1101
1254
  }) {
1255
+ // Voice fast-path is structural only: short reply, no tools/failures.
1256
+ // Content terminality is left to the model completion judge.
1102
1257
  return options.latencyProfile === 'voice'
1103
1258
  && toolExecutions.length === 0
1104
1259
  && failedStepCount === 0
@@ -1222,15 +1377,37 @@ class AgentEngine {
1222
1377
  }
1223
1378
 
1224
1379
  async runWithModel(userId, userMessage, options = {}, _modelOverride = null) {
1225
- return runConversation(this, userId, userMessage, options, _modelOverride);
1226
- }
1227
-
1228
- async _runWithModelInternal(userId, userMessage, options = {}, _modelOverride = null) {
1229
- 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
+ }
1230
1395
  }
1231
1396
 
1232
1397
  async spawnSubagent(userId, parentRunId, task, options = {}) {
1233
- 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
+ }
1234
1411
  const parentDepth = Math.max(0, Number(parentRunMeta?.subagentDepth) || 0);
1235
1412
  if (parentDepth >= 1) {
1236
1413
  return {
@@ -1240,24 +1417,77 @@ class AgentEngine {
1240
1417
 
1241
1418
  const aiSettings = getAiSettings(userId, options.agentId || null);
1242
1419
  const maxSubagentsPerRun = Math.max(1, Number(aiSettings.subagent_max_children_per_run) || 10);
1243
- const existingSubagents = Array.from(this.subagents.values())
1244
- .filter((record) => record.parentRunId === parentRunId);
1245
- if (existingSubagents.length >= maxSubagentsPerRun) {
1420
+ const countExistingSubagents = () => Array.from(this.subagents.values())
1421
+ .filter((record) => record.parentRunId === parentRunId).length;
1422
+ if (countExistingSubagents() >= maxSubagentsPerRun) {
1246
1423
  return {
1247
- 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}.`,
1248
1425
  };
1249
1426
  }
1250
1427
 
1251
1428
  const handle = uuidv4();
1252
1429
  const childRunId = uuidv4();
1253
1430
  let relevantMemories = [];
1431
+ const startupLink = createLinkedAbortController([
1432
+ this.lifecycleAbortController.signal,
1433
+ parentRunMeta.abortController?.signal,
1434
+ options.signal,
1435
+ ]);
1436
+ let memoryRecall = null;
1254
1437
  try {
1255
- relevantMemories = this.memoryManager
1256
- ? await this.memoryManager.recallMemory(userId, task, 4, {
1257
- agentId: options.agentId || null,
1258
- })
1259
- : [];
1260
- } 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.');
1261
1491
  const subEngine = new AgentEngine(this.io, {
1262
1492
  app: options.app || this.app,
1263
1493
  browserController: this.browserController,
@@ -1303,6 +1533,8 @@ class AgentEngine {
1303
1533
  error: null,
1304
1534
  engine: subEngine,
1305
1535
  promise: null,
1536
+ settled: false,
1537
+ deleteWhenSettled: false,
1306
1538
  };
1307
1539
  this.subagents.set(handle, record);
1308
1540
  this.emit(userId, 'run:subagent', {
@@ -1326,9 +1558,11 @@ class AgentEngine {
1326
1558
  agentId: options.agentId || null,
1327
1559
  subagentDepth: parentDepth + 1,
1328
1560
  disallowedToolNames: ['spawn_subagent'],
1561
+ signal: childLink.signal,
1329
1562
  },
1330
1563
  options.model || null
1331
1564
  );
1565
+ if (record.status === 'cancelled' || childLink.signal.aborted) return record;
1332
1566
  record.status = result.status || 'completed';
1333
1567
  let structured = null;
1334
1568
  try {
@@ -1356,16 +1590,23 @@ class AgentEngine {
1356
1590
  });
1357
1591
  return record;
1358
1592
  } catch (err) {
1359
- record.status = 'failed';
1360
- 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));
1361
1596
  this.emit(userId, 'run:subagent', {
1362
1597
  runId: parentRunId,
1363
1598
  handle,
1364
1599
  childRunId,
1365
- status: 'failed',
1366
- error: err.message,
1600
+ status: record.status,
1601
+ error: record.error,
1367
1602
  });
1368
- 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
+ }
1369
1610
  }
1370
1611
  })();
1371
1612
 
@@ -1502,21 +1743,43 @@ class AgentEngine {
1502
1743
  }));
1503
1744
  }
1504
1745
 
1505
- cleanupSubagentsForRun(parentRunId, options = {}) {
1506
- if (!parentRunId) return;
1746
+ async cleanupSubagentsForRun(parentRunId, options = {}) {
1747
+ if (!parentRunId) return { cancelled: 0, timedOut: 0 };
1507
1748
  const cancelRunning = options.cancelRunning !== false;
1508
- 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())) {
1509
1752
  if (record.parentRunId !== parentRunId) continue;
1753
+ records.push([handle, record]);
1754
+ record.deleteWhenSettled = true;
1510
1755
  if (cancelRunning && record.status === 'running') {
1511
- try {
1512
- record.engine?.abort(record.childRunId);
1513
- record.status = 'cancelled';
1514
- } catch (err) {
1515
- console.warn(`[AgentEngine] Failed to abort subagent ${handle}:`, err?.message);
1516
- }
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);
1517
1775
  }
1518
- this.subagents.delete(handle);
1519
1776
  }
1777
+ return {
1778
+ cancelled: shutdowns.length,
1779
+ timedOut: results.filter((result) => (
1780
+ result.status === 'fulfilled' && result.value?.timedOut === true
1781
+ )).length,
1782
+ };
1520
1783
  }
1521
1784
 
1522
1785
  async waitForSubagent(handle, options = {}) {
@@ -1579,7 +1842,6 @@ class AgentEngine {
1579
1842
  };
1580
1843
  }
1581
1844
 
1582
- record.engine?.abort(record.childRunId);
1583
1845
  record.status = 'cancelled';
1584
1846
  this.emit(record.userId, 'run:subagent', {
1585
1847
  runId: record.parentRunId,
@@ -1588,11 +1850,23 @@ class AgentEngine {
1588
1850
  status: 'cancelled',
1589
1851
  });
1590
1852
 
1591
- 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
+ };
1592
1862
  }
1593
1863
 
1594
1864
  interruptRun(runId, reason = 'Server shutting down while run was in progress.') {
1595
1865
  const runMeta = this.activeRuns.get(runId);
1866
+ const persistedRun = runMeta || db.prepare('SELECT user_id AS userId FROM agent_runs WHERE id = ?').get(runId);
1867
+ if (persistedRun?.userId != null) {
1868
+ requestRunControl(runId, persistedRun.userId, 'interrupt', reason);
1869
+ }
1596
1870
  const delegatedChildren = db.prepare(
1597
1871
  "SELECT child_run_id FROM agent_delegations WHERE parent_run_id = ? AND status = 'running'"
1598
1872
  ).all(runId);
@@ -1600,6 +1874,8 @@ class AgentEngine {
1600
1874
  runMeta.status = 'interrupted';
1601
1875
  runMeta.stopReason = reason;
1602
1876
  runMeta.aborted = true;
1877
+ runMeta.abortController?.abort(reason);
1878
+ runMeta.resumeRun?.();
1603
1879
  this.emit(runMeta.userId, 'run:stopping', { runId });
1604
1880
  for (const pid of runMeta.toolPids) {
1605
1881
  if (this.runtimeManager && typeof this.runtimeManager.killCommand === 'function') {
@@ -1621,14 +1897,7 @@ class AgentEngine {
1621
1897
  completed_at = datetime('now')
1622
1898
  WHERE parent_run_id = ? AND status = 'running'`
1623
1899
  ).run(reason, runId);
1624
- db.prepare(
1625
- `UPDATE agent_runs
1626
- SET status = 'interrupted',
1627
- error = COALESCE(NULLIF(error, ''), ?),
1628
- updated_at = datetime('now'),
1629
- completed_at = datetime('now')
1630
- WHERE id = ?`
1631
- ).run(reason, runId);
1900
+ closeRun(runId, 'interrupted', { error: reason }, ['running', 'pausing', 'paused', 'resuming']);
1632
1901
  }
1633
1902
 
1634
1903
  interruptAllActiveRuns(reason = 'Server shutting down while run was in progress.') {
@@ -1637,15 +1906,26 @@ class AgentEngine {
1637
1906
  }
1638
1907
  }
1639
1908
 
1640
- stopRun(runId) {
1909
+ stopRun(runId, reason = 'Stopped by request.') {
1910
+ const stopReason = String(reason || 'Stopped by request.').slice(0, 1000);
1641
1911
  const runMeta = this.activeRuns.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;
1916
+ if (persistedRun?.userId != null) {
1917
+ requestRunControl(runId, persistedRun.userId, 'stop', stopReason);
1918
+ }
1642
1919
  const delegatedChildren = db.prepare(
1643
1920
  "SELECT child_run_id FROM agent_delegations WHERE parent_run_id = ? AND status = 'running'"
1644
1921
  ).all(runId);
1645
1922
  if (runMeta) {
1646
1923
  runMeta.status = 'stopped';
1924
+ runMeta.stopReason = stopReason;
1647
1925
  runMeta.aborted = true;
1648
- 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 });
1649
1929
  for (const pid of runMeta.toolPids) {
1650
1930
  if (this.runtimeManager && typeof this.runtimeManager.killCommand === 'function') {
1651
1931
  void this.runtimeManager.killCommand(runMeta.userId, pid, 'aborted');
@@ -1655,24 +1935,74 @@ class AgentEngine {
1655
1935
  }
1656
1936
  for (const child of delegatedChildren) {
1657
1937
  if (child.child_run_id && child.child_run_id !== runId) {
1658
- this.stopRun(child.child_run_id);
1938
+ this.stopRun(child.child_run_id, stopReason);
1659
1939
  }
1660
1940
  }
1661
1941
  db.prepare(
1662
- "UPDATE agent_delegations SET status = 'stopped', updated_at = datetime('now'), completed_at = datetime('now') WHERE parent_run_id = ? AND status = 'running'"
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;
1954
+ }
1955
+
1956
+ pauseRun(runId, { userId, reason = '' } = {}) {
1957
+ if (!runId || userId == null) return false;
1958
+ const runMeta = this.activeRuns.get(runId);
1959
+ if (
1960
+ !runMeta
1961
+ || Number(runMeta.userId) !== Number(userId)
1962
+ || runMeta.status !== 'running'
1963
+ || runMeta.pauseAvailable !== true
1964
+ ) return false;
1965
+ const result = requestRunControl(runId, userId, 'pause', reason);
1966
+ if (!result.accepted) return false;
1967
+ transitionRun(runId, 'pausing', {}, ['running']);
1968
+ runMeta.status = 'pausing';
1969
+ runMeta.abortController?.abort('Run paused.');
1970
+ for (const pid of runMeta.toolPids) {
1971
+ if (this.runtimeManager && typeof this.runtimeManager.killCommand === 'function') {
1972
+ void this.runtimeManager.killCommand(runMeta.userId, pid, 'paused');
1973
+ }
1974
+ }
1975
+ this.emit(runMeta.userId, 'run:pausing', { runId, reason: reason || null });
1976
+ return true;
1977
+ }
1978
+
1979
+ resumeRun(runId, { userId } = {}) {
1980
+ if (!runId || userId == null) return false;
1981
+ const runMeta = this.activeRuns.get(runId);
1982
+ if (!runMeta || Number(runMeta.userId) !== Number(userId) || runMeta.status !== 'paused') return false;
1983
+ if (!transitionRun(runId, 'resuming', {}, ['paused'])) return false;
1984
+ db.prepare(
1985
+ `UPDATE agent_run_controls SET consumed_at = datetime('now')
1986
+ WHERE run_id = ? AND action = 'pause' AND consumed_at IS NULL`,
1663
1987
  ).run(runId);
1664
- db.prepare("UPDATE agent_runs SET status = 'stopped', updated_at = datetime('now') WHERE id = ?").run(runId);
1988
+ transitionRun(runId, 'running', {}, ['resuming']);
1989
+ this.emit(runMeta.userId, 'run:resumed', { runId });
1990
+ this.recordRunEvent(runMeta.userId, runId, 'run_resumed', {}, { agentId: runMeta.agentId });
1991
+ runMeta.resumeRun?.();
1992
+ return true;
1665
1993
  }
1666
1994
 
1667
- abort(runId, { userId } = {}) {
1995
+ abort(runId, { userId, reason = 'Stopped by request.' } = {}) {
1668
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;
1669
2001
  if (userId != null) {
1670
- // Ownership gate: never let one user abort another user's active run.
1671
- const runMeta = this.activeRuns.get(runId);
1672
- 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;
1673
2004
  }
1674
- this.stopRun(runId);
1675
- return true;
2005
+ return this.stopRun(runId, reason);
1676
2006
  }
1677
2007
 
1678
2008
  abortAll(userId) {