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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/extensions/chrome-browser/background.mjs +318 -88
  2. package/extensions/chrome-browser/http.mjs +136 -0
  3. package/extensions/chrome-browser/protocol.mjs +511 -89
  4. package/flutter_app/lib/main_chat.dart +118 -739
  5. package/flutter_app/lib/main_controller.dart +29 -20
  6. package/flutter_app/lib/main_models.dart +3 -0
  7. package/flutter_app/lib/main_operations.dart +334 -321
  8. package/flutter_app/lib/main_settings.dart +4 -3
  9. package/flutter_app/lib/main_shared.dart +14 -11
  10. package/flutter_app/lib/src/desktop_companion_actions.dart +185 -31
  11. package/flutter_app/lib/src/desktop_companion_io.dart +319 -86
  12. package/flutter_app/windows/runner/flutter_window.cpp +143 -32
  13. package/lib/manager.js +106 -89
  14. package/lib/schema_migrations.js +67 -13
  15. package/package.json +20 -13
  16. package/runtime/paths.js +49 -5
  17. package/server/guest_agent.js +52 -30
  18. package/server/http/middleware.js +24 -0
  19. package/server/http/routes.js +4 -6
  20. package/server/public/.last_build_id +1 -1
  21. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  22. package/server/public/flutter_bootstrap.js +1 -1
  23. package/server/public/main.dart.js +30803 -30805
  24. package/server/routes/admin.js +1 -1
  25. package/server/routes/android.js +30 -34
  26. package/server/routes/browser.js +23 -15
  27. package/server/routes/desktop.js +18 -1
  28. package/server/routes/integrations.js +5 -1
  29. package/server/routes/memory.js +1 -0
  30. package/server/routes/settings.js +16 -5
  31. package/server/routes/social_reach.js +12 -3
  32. package/server/routes/social_video.js +4 -0
  33. package/server/services/ai/compaction.js +7 -2
  34. package/server/services/ai/history.js +44 -5
  35. package/server/services/ai/integrated_tools/http_request.js +8 -0
  36. package/server/services/ai/loop/agent_engine_core.js +358 -162
  37. package/server/services/ai/loop/callbacks.js +1 -0
  38. package/server/services/ai/loop/completion_judge.js +12 -0
  39. package/server/services/ai/loop/conversation_loop.js +348 -242
  40. package/server/services/ai/loop/messaging_delivery.js +129 -57
  41. package/server/services/ai/loop/model_call_guard.js +91 -0
  42. package/server/services/ai/loop/model_io.js +20 -45
  43. package/server/services/ai/loop/tool_dispatch.js +19 -8
  44. package/server/services/ai/loopPolicy.js +24 -19
  45. package/server/services/ai/model_discovery.js +227 -0
  46. package/server/services/ai/model_identity.js +71 -0
  47. package/server/services/ai/models.js +67 -162
  48. package/server/services/ai/providerRetry.js +17 -59
  49. package/server/services/ai/provider_selector.js +111 -0
  50. package/server/services/ai/providers/anthropic.js +2 -2
  51. package/server/services/ai/providers/claudeCode.js +21 -33
  52. package/server/services/ai/providers/githubCopilot.js +41 -20
  53. package/server/services/ai/providers/google.js +23 -5
  54. package/server/services/ai/providers/grok.js +4 -3
  55. package/server/services/ai/providers/grokOauth.js +19 -27
  56. package/server/services/ai/providers/nvidia.js +10 -5
  57. package/server/services/ai/providers/ollama.js +111 -84
  58. package/server/services/ai/providers/ollama_stream.js +142 -0
  59. package/server/services/ai/providers/openai.js +39 -5
  60. package/server/services/ai/providers/openaiCodex.js +11 -4
  61. package/server/services/ai/providers/openrouter.js +29 -7
  62. package/server/services/ai/providers/provider_error.js +36 -0
  63. package/server/services/ai/settings.js +26 -2
  64. package/server/services/ai/taskAnalysis.js +5 -1
  65. package/server/services/ai/terminal_reply.js +45 -0
  66. package/server/services/ai/toolEvidence.js +58 -29
  67. package/server/services/ai/tools.js +124 -111
  68. package/server/services/android/controller.js +770 -237
  69. package/server/services/android/process.js +140 -0
  70. package/server/services/android/sdk_download.js +143 -0
  71. package/server/services/android/uia.js +6 -5
  72. package/server/services/artifacts/store.js +24 -0
  73. package/server/services/browser/controller.js +736 -385
  74. package/server/services/browser/extension/gateway.js +40 -16
  75. package/server/services/browser/extension/protocol.js +12 -1
  76. package/server/services/browser/extension/provider.js +59 -47
  77. package/server/services/browser/extension/registry.js +155 -34
  78. package/server/services/cli/executor.js +62 -9
  79. package/server/services/desktop/gateway.js +41 -4
  80. package/server/services/desktop/protocol.js +3 -0
  81. package/server/services/desktop/provider.js +39 -42
  82. package/server/services/desktop/registry.js +137 -52
  83. package/server/services/integrations/figma/provider.js +78 -12
  84. package/server/services/integrations/github/common.js +11 -6
  85. package/server/services/integrations/github/provider.js +52 -53
  86. package/server/services/integrations/google/provider.js +55 -19
  87. package/server/services/integrations/home_assistant/network.js +17 -20
  88. package/server/services/integrations/home_assistant/provider.js +7 -5
  89. package/server/services/integrations/home_assistant/tools.js +17 -5
  90. package/server/services/integrations/http.js +51 -0
  91. package/server/services/integrations/manager.js +158 -53
  92. package/server/services/integrations/microsoft/provider.js +80 -13
  93. package/server/services/integrations/neoarchive/provider.js +55 -29
  94. package/server/services/integrations/neorecall/client.js +17 -10
  95. package/server/services/integrations/neorecall/provider.js +20 -11
  96. package/server/services/integrations/notion/provider.js +16 -13
  97. package/server/services/integrations/oauth_provider.js +115 -51
  98. package/server/services/integrations/slack/provider.js +98 -9
  99. package/server/services/integrations/spotify/provider.js +67 -71
  100. package/server/services/integrations/trello/provider.js +21 -7
  101. package/server/services/integrations/weather/provider.js +18 -12
  102. package/server/services/integrations/whatsapp/provider.js +76 -16
  103. package/server/services/manager.js +87 -1
  104. package/server/services/memory/embedding_index.js +20 -8
  105. package/server/services/memory/embeddings.js +151 -90
  106. package/server/services/memory/ingestion.js +50 -9
  107. package/server/services/memory/ingestion_documents.js +13 -3
  108. package/server/services/memory/manager.js +52 -19
  109. package/server/services/messaging/automation.js +84 -9
  110. package/server/services/messaging/http_platforms.js +33 -13
  111. package/server/services/messaging/inbound_queue.js +78 -24
  112. package/server/services/messaging/inbound_store.js +224 -0
  113. package/server/services/messaging/manager.js +326 -51
  114. package/server/services/messaging/typing_keepalive.js +5 -2
  115. package/server/services/network/http.js +210 -0
  116. package/server/services/network/safe_request.js +307 -0
  117. package/server/services/runtime/backends/local-vm.js +214 -66
  118. package/server/services/runtime/manager.js +17 -12
  119. package/server/services/social_reach/channels/github.js +10 -4
  120. package/server/services/social_reach/channels/reddit.js +4 -4
  121. package/server/services/social_reach/channels/rss.js +2 -2
  122. package/server/services/social_reach/channels/social_video.js +12 -7
  123. package/server/services/social_reach/channels/v2ex.js +21 -8
  124. package/server/services/social_reach/channels/x.js +2 -2
  125. package/server/services/social_reach/channels/xueqiu.js +5 -5
  126. package/server/services/social_reach/service.js +9 -6
  127. package/server/services/social_reach/utils.js +65 -14
  128. package/server/services/social_video/service.js +160 -50
  129. package/server/services/tasks/integration_runtime.js +18 -8
  130. package/server/services/tasks/runtime.js +39 -4
  131. package/server/services/voice/agentBridge.js +17 -4
  132. package/server/services/voice/bufferedLiveRelayAdapter.js +5 -0
  133. package/server/services/voice/liveSession.js +31 -0
  134. package/server/services/voice/openaiSpeech.js +33 -8
  135. package/server/services/voice/providers.js +233 -151
  136. package/server/services/voice/runtimeManager.js +118 -20
  137. package/server/services/voice/turnRunner.js +6 -0
  138. package/server/services/wearable/firmware_manifest.js +51 -13
  139. package/server/services/wearable/service.js +1 -0
  140. package/server/utils/abort.js +96 -0
  141. package/server/utils/cloud-security.js +110 -3
  142. package/server/utils/files.js +31 -0
  143. package/server/utils/image_payload.js +95 -0
  144. package/server/utils/retry.js +107 -0
@@ -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,7 @@ const {
30
32
  const {
31
33
  buildChurnAssessmentPrompt,
32
34
  buildCompletionDecisionPrompt,
35
+ enforceTerminalReplyDecision,
33
36
  normalizeChurnAssessment,
34
37
  normalizeCompletionDecision,
35
38
  resolveRunGoalContext,
@@ -67,7 +70,7 @@ const {
67
70
  const {
68
71
  requestModelResponse: requestModelResponseImpl,
69
72
  requestStructuredJson: requestStructuredJsonImpl,
70
- withModelCallTimeout,
73
+ runAbortableModelCall,
71
74
  } = require('./model_io');
72
75
  const {
73
76
  publishInterimUpdate: publishInterimUpdateImpl,
@@ -82,6 +85,7 @@ const {
82
85
  normalizeOutgoingMessage,
83
86
  clampRunContext,
84
87
  } = require('../messagingFallback');
88
+ const { isDeferredWorkReply } = require('../terminal_reply');
85
89
  const {
86
90
  summarizeToolExecutions,
87
91
  } = require('../toolEvidence');
@@ -97,6 +101,13 @@ const {
97
101
  normalizeRetrievalPlan,
98
102
  shouldEnhanceRetrieval,
99
103
  } = require('../../memory/retrieval_reasoning');
104
+ const {
105
+ createAbortError,
106
+ createLinkedAbortController,
107
+ isAbortError,
108
+ runWithAbortTimeout,
109
+ throwIfAborted,
110
+ } = require('../../../utils/abort');
100
111
 
101
112
  function buildInitialRunMetadata(options = {}) {
102
113
  const metadata = {};
@@ -106,6 +117,9 @@ function buildInitialRunMetadata(options = {}) {
106
117
  if (options.widgetId != null && String(options.widgetId).trim()) {
107
118
  metadata.widgetId = options.widgetId;
108
119
  }
120
+ if (options.messagingInboundJobId != null && String(options.messagingInboundJobId).trim()) {
121
+ metadata.messagingInboundJobId = String(options.messagingInboundJobId).trim();
122
+ }
109
123
  return metadata;
110
124
  }
111
125
 
@@ -131,99 +145,6 @@ function buildAnalyzeTaskFallback(forceMode, userMessage = '') {
131
145
  };
132
146
  }
133
147
 
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
148
  function estimateTokenValue(value) {
228
149
  if (!value) return 0;
229
150
  if (typeof value === 'string') return Math.ceil(value.length / 4);
@@ -234,6 +155,13 @@ class AgentEngine {
234
155
  constructor(io, services = {}) {
235
156
  this.io = io;
236
157
  this.activeRuns = new Map();
158
+ this.activeRunPromises = new Set();
159
+ this.backgroundTasks = new Set();
160
+ this.backgroundTaskQueues = new Map();
161
+ this.subagentStartupTasks = new Set();
162
+ this.lifecycleAbortController = new AbortController();
163
+ this.shuttingDown = false;
164
+ this.shutdownPromise = null;
237
165
  this.subagents = new Map();
238
166
  this.app = services.app || null;
239
167
  this.browserController = services.browserController || null;
@@ -249,6 +177,107 @@ class AgentEngine {
249
177
  this.messagingDeliveryRetry = services.messagingDeliveryRetry || {};
250
178
  }
251
179
 
180
+ trackBackgroundTask(task, options = {}) {
181
+ if (typeof task !== 'function') {
182
+ throw new TypeError('Background task must be a function.');
183
+ }
184
+ if (this.shuttingDown || this.lifecycleAbortController.signal.aborted) {
185
+ return Promise.reject(createAbortError(
186
+ this.lifecycleAbortController.signal,
187
+ 'Agent engine is shutting down.',
188
+ ));
189
+ }
190
+
191
+ const key = String(options.key || '').trim();
192
+ const previous = key ? this.backgroundTaskQueues.get(key) : null;
193
+ if (previous && options.coalesce === true) return previous;
194
+
195
+ const linked = createLinkedAbortController([
196
+ this.lifecycleAbortController.signal,
197
+ options.signal,
198
+ ]);
199
+ const tracked = Promise.resolve().then(async () => {
200
+ if (previous) {
201
+ try {
202
+ await previous;
203
+ } catch {
204
+ // A failed predecessor must not permanently poison this queue.
205
+ }
206
+ }
207
+ throwIfAborted(linked.signal, 'Background task aborted.');
208
+ return task(linked.signal);
209
+ }).finally(() => {
210
+ linked.cleanup();
211
+ });
212
+
213
+ this.backgroundTasks.add(tracked);
214
+ if (key) this.backgroundTaskQueues.set(key, tracked);
215
+ const cleanup = () => {
216
+ this.backgroundTasks.delete(tracked);
217
+ if (key && this.backgroundTaskQueues.get(key) === tracked) {
218
+ this.backgroundTaskQueues.delete(key);
219
+ }
220
+ };
221
+ tracked.then(cleanup, cleanup);
222
+ return tracked;
223
+ }
224
+
225
+ shutdown(options = {}) {
226
+ if (this.shutdownPromise) return this.shutdownPromise;
227
+
228
+ const reason = String(
229
+ options.reason || 'Server shutting down while agent work was in progress.',
230
+ );
231
+ const timeoutMs = Math.max(100, Number(options.timeoutMs) || 10000);
232
+ this.shuttingDown = true;
233
+ this.lifecycleAbortController.abort(reason);
234
+ this.interruptAllActiveRuns(reason);
235
+
236
+ const childShutdowns = [];
237
+ const childPromises = [];
238
+ for (const record of this.subagents.values()) {
239
+ if (typeof record.engine?.shutdown === 'function') {
240
+ childShutdowns.push(record.engine.shutdown({ reason, timeoutMs }));
241
+ }
242
+ if (record.promise) childPromises.push(record.promise);
243
+ }
244
+ const pending = [
245
+ ...this.activeRunPromises,
246
+ ...this.backgroundTasks,
247
+ ...this.subagentStartupTasks,
248
+ ...childShutdowns,
249
+ ...childPromises,
250
+ ];
251
+
252
+ this.shutdownPromise = (async () => {
253
+ if (pending.length === 0) {
254
+ return { state: 'stopped', timedOut: false, pendingCount: 0 };
255
+ }
256
+
257
+ let timeout = null;
258
+ const timeoutResult = Symbol('agent-engine-shutdown-timeout');
259
+ const result = await Promise.race([
260
+ Promise.allSettled(pending),
261
+ new Promise((resolve) => {
262
+ timeout = setTimeout(() => resolve(timeoutResult), timeoutMs);
263
+ }),
264
+ ]);
265
+ if (timeout) clearTimeout(timeout);
266
+ const timedOut = result === timeoutResult;
267
+ return {
268
+ state: timedOut ? 'timeout' : 'stopped',
269
+ timedOut,
270
+ pendingCount: timedOut
271
+ ? this.activeRunPromises.size
272
+ + this.backgroundTasks.size
273
+ + this.subagentStartupTasks.size
274
+ + Array.from(this.subagents.values()).filter((record) => !record.settled).length
275
+ : 0,
276
+ };
277
+ })();
278
+ return this.shutdownPromise;
279
+ }
280
+
252
281
  async buildSystemPrompt(userId, context = {}) {
253
282
  const { buildSystemPromptSections } = require('../systemPrompt');
254
283
  const { MemoryManager } = require('../../memory/manager');
@@ -287,18 +316,34 @@ class AgentEngine {
287
316
  options = {},
288
317
  returnDetails = false,
289
318
  }) {
290
- const initial = await memoryManager.recallMemory(userId, query, 12, { agentId });
319
+ const signal = options.signal || this.getRunMeta(runId)?.abortController?.signal || null;
320
+ const initial = await memoryManager.recallMemory(userId, query, 12, {
321
+ agentId,
322
+ signal,
323
+ });
291
324
 
292
325
  const pendingChunks = memoryManager.getPendingExtractionChunks?.(userId, agentId, 5) || [];
293
326
  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));
327
+ this.trackBackgroundTask((backgroundSignal) => this.extractPendingChunks(
328
+ pendingChunks,
329
+ {
330
+ userId,
331
+ agentId,
332
+ provider,
333
+ providerName,
334
+ model,
335
+ memoryManager,
336
+ signal: backgroundSignal,
337
+ },
338
+ ), {
339
+ key: `memory-extraction:${userId}:${agentId || 'main'}`,
340
+ coalesce: true,
341
+ signal,
342
+ }).catch((err) => {
343
+ if (!isAbortError(err, signal) && !this.shuttingDown) {
344
+ console.warn('[Memory] Background chunk extraction failed:', err.message);
345
+ }
346
+ });
302
347
  }
303
348
 
304
349
  const decision = shouldEnhanceRetrieval(initial);
@@ -335,7 +380,7 @@ class AgentEngine {
335
380
  normalize: (raw) => normalizeRetrievalPlan(raw, query),
336
381
  fallback: normalizeRetrievalPlan({}, query),
337
382
  reasoningEffort: this.getReasoningEffort(providerName, options),
338
- telemetry: { runId, stepId, userId, agentId },
383
+ telemetry: { runId, stepId, userId, agentId, signal },
339
384
  phase: 'memory_retrieval_plan',
340
385
  });
341
386
  plan = planned.value;
@@ -346,6 +391,7 @@ class AgentEngine {
346
391
  agentId,
347
392
  validAt: plan.validAt,
348
393
  includeHistory: plan.temporalMode === 'historical',
394
+ signal,
349
395
  }));
350
396
  }
351
397
  merged = mergeRetrievalResults(resultSets, 30);
@@ -360,7 +406,7 @@ class AgentEngine {
360
406
  normalize: (raw) => normalizeRerankResult(raw, merged),
361
407
  fallback: merged,
362
408
  reasoningEffort: this.getReasoningEffort(providerName, options),
363
- telemetry: { runId, stepId, userId, agentId },
409
+ telemetry: { runId, stepId, userId, agentId, signal },
364
410
  phase: 'memory_retrieval_rerank',
365
411
  });
366
412
  reranked = rerankResponse.value;
@@ -368,6 +414,7 @@ class AgentEngine {
368
414
  reranked = merged;
369
415
  }
370
416
  } catch (error) {
417
+ if (isAbortError(error, signal)) throw error;
371
418
  console.warn('[Memory] Retrieval enhancement failed:', error.message);
372
419
  plan = null;
373
420
  merged = initial;
@@ -406,10 +453,8 @@ class AgentEngine {
406
453
  providerName,
407
454
  model,
408
455
  memoryManager,
456
+ signal = null,
409
457
  }) {
410
- const ids = chunks.map((c) => c.id);
411
- memoryManager.markChunksExtracted?.(ids, { success: true });
412
-
413
458
  const consolidationSchema = JSON.stringify({
414
459
  memory_candidates: [{
415
460
  memory: 'Concise standalone fact.',
@@ -430,6 +475,7 @@ class AgentEngine {
430
475
 
431
476
  for (const chunk of chunks) {
432
477
  try {
478
+ throwIfAborted(signal, 'Memory extraction aborted.');
433
479
  const result = await this.requestStructuredJson({
434
480
  provider,
435
481
  providerName,
@@ -446,6 +492,7 @@ class AgentEngine {
446
492
  maxTokens: 800,
447
493
  normalize: (raw) => normalizeMemoryCandidates(raw?.memory_candidates || []),
448
494
  fallback: [],
495
+ telemetry: { userId, agentId, signal },
449
496
  phase: 'document_extraction',
450
497
  });
451
498
 
@@ -457,10 +504,13 @@ class AgentEngine {
457
504
  trustLevel: 'external_source',
458
505
  sourceChunkMemoryId: chunk.id,
459
506
  },
507
+ signal,
460
508
  });
461
509
  }
510
+ memoryManager.markChunksExtracted?.([chunk.id], { success: true });
462
511
  } catch (err) {
463
512
  memoryManager.markChunksExtracted?.([chunk.id], { success: false });
513
+ if (isAbortError(err, signal)) throw err;
464
514
  console.warn('[Memory] Document chunk extraction failed:', err.message);
465
515
  }
466
516
  }
@@ -523,6 +573,7 @@ class AgentEngine {
523
573
  ? deliverableResult.artifacts.slice(0, 6)
524
574
  : [],
525
575
  },
576
+ signal: this.getRunMeta(runId)?.abortController?.signal || null,
526
577
  },
527
578
  );
528
579
  } catch (error) {
@@ -766,7 +817,7 @@ class AgentEngine {
766
817
  phase: 'completion_decision',
767
818
  });
768
819
  return {
769
- decision: response.value,
820
+ decision: enforceTerminalReplyDecision(response.value, lastReply),
770
821
  usage: response.usage,
771
822
  raw: response.raw,
772
823
  };
@@ -931,11 +982,12 @@ class AgentEngine {
931
982
  }
932
983
  ];
933
984
 
934
- const response = await withModelCallTimeout(
935
- provider.chat(promptMessages, [], {
985
+ const response = await runAbortableModelCall(
986
+ (signal) => provider.chat(promptMessages, [], {
936
987
  model,
937
988
  maxTokens: 800,
938
989
  reasoningEffort: this.getReasoningEffort(providerName, options),
990
+ signal,
939
991
  }),
940
992
  options,
941
993
  'Conversation state refresh',
@@ -966,6 +1018,7 @@ class AgentEngine {
966
1018
  agentId: options.agentId || null,
967
1019
  conversationId,
968
1020
  runId,
1021
+ signal: options.signal,
969
1022
  },
970
1023
  );
971
1024
  const { invalidateSystemPromptCache } = require('../systemPrompt');
@@ -982,8 +1035,8 @@ class AgentEngine {
982
1035
  return executeToolImpl(this, toolName, args, context);
983
1036
  }
984
1037
 
985
- isReadOnlyToolCall(toolCall) {
986
- return isReadOnlyToolCallImpl(this, toolCall);
1038
+ isReadOnlyToolCall(toolCall, toolDefinition = null) {
1039
+ return isReadOnlyToolCallImpl(toolCall, toolDefinition);
987
1040
  }
988
1041
 
989
1042
  async executeReadOnlyBatch(toolCalls, context = {}) {
@@ -1119,6 +1172,15 @@ class AgentEngine {
1119
1172
 
1120
1173
  await new Promise((resolve) => { runMeta.resumeRun = resolve; });
1121
1174
  runMeta.resumeRun = null;
1175
+ if (runMeta.status === 'stopped' || runMeta.status === 'interrupted') {
1176
+ return { action: runMeta.status === 'interrupted' ? 'interrupt' : 'stop' };
1177
+ }
1178
+ const persistedStatus = db.prepare('SELECT status FROM agent_runs WHERE id = ?').get(runId)?.status;
1179
+ if (persistedStatus !== 'running') {
1180
+ const action = persistedStatus === 'interrupted' ? 'interrupt' : 'stop';
1181
+ runMeta.status = persistedStatus || 'stopped';
1182
+ return { action };
1183
+ }
1122
1184
  runMeta.abortController = new AbortController();
1123
1185
  runMeta.status = 'running';
1124
1186
  this.startMessagingProgressSupervisor(runId);
@@ -1163,7 +1225,8 @@ class AgentEngine {
1163
1225
  && toolExecutions.length === 0
1164
1226
  && failedStepCount === 0
1165
1227
  && !messagingSent
1166
- && Boolean(String(lastReply || '').trim());
1228
+ && Boolean(String(lastReply || '').trim())
1229
+ && !isDeferredWorkReply(lastReply);
1167
1230
  }
1168
1231
 
1169
1232
  getMessagingRetryLimit(maxIterations) {
@@ -1282,15 +1345,37 @@ class AgentEngine {
1282
1345
  }
1283
1346
 
1284
1347
  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);
1348
+ if (this.shuttingDown || this.lifecycleAbortController.signal.aborted) {
1349
+ const error = createAbortError(
1350
+ this.lifecycleAbortController.signal,
1351
+ 'Agent engine is shutting down.',
1352
+ );
1353
+ error.code = error.code || 'AGENT_ENGINE_SHUTTING_DOWN';
1354
+ throw error;
1355
+ }
1356
+ const runPromise = runConversation(this, userId, userMessage, options, _modelOverride);
1357
+ this.activeRunPromises.add(runPromise);
1358
+ try {
1359
+ return await runPromise;
1360
+ } finally {
1361
+ this.activeRunPromises.delete(runPromise);
1362
+ }
1290
1363
  }
1291
1364
 
1292
1365
  async spawnSubagent(userId, parentRunId, task, options = {}) {
1293
- const parentRunMeta = this.getRunMeta(parentRunId);
1366
+ if (this.shuttingDown || this.lifecycleAbortController.signal.aborted) {
1367
+ throw createAbortError(this.lifecycleAbortController.signal, 'Agent engine is shutting down.');
1368
+ }
1369
+
1370
+ let parentRunMeta = this.getRunMeta(parentRunId);
1371
+ if (
1372
+ !parentRunMeta
1373
+ || parentRunMeta.userId !== userId
1374
+ || parentRunMeta.status !== 'running'
1375
+ || parentRunMeta.aborted === true
1376
+ ) {
1377
+ return { error: 'The parent run is no longer active, so a sub-agent cannot be started.' };
1378
+ }
1294
1379
  const parentDepth = Math.max(0, Number(parentRunMeta?.subagentDepth) || 0);
1295
1380
  if (parentDepth >= 1) {
1296
1381
  return {
@@ -1300,24 +1385,77 @@ class AgentEngine {
1300
1385
 
1301
1386
  const aiSettings = getAiSettings(userId, options.agentId || null);
1302
1387
  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) {
1388
+ const countExistingSubagents = () => Array.from(this.subagents.values())
1389
+ .filter((record) => record.parentRunId === parentRunId).length;
1390
+ if (countExistingSubagents() >= maxSubagentsPerRun) {
1306
1391
  return {
1307
- error: `This run has already spawned ${existingSubagents.length} sub-agents. The limit for one run is ${maxSubagentsPerRun}.`,
1392
+ error: `This run has already spawned ${countExistingSubagents()} sub-agents. The limit for one run is ${maxSubagentsPerRun}.`,
1308
1393
  };
1309
1394
  }
1310
1395
 
1311
1396
  const handle = uuidv4();
1312
1397
  const childRunId = uuidv4();
1313
1398
  let relevantMemories = [];
1399
+ const startupLink = createLinkedAbortController([
1400
+ this.lifecycleAbortController.signal,
1401
+ parentRunMeta.abortController?.signal,
1402
+ options.signal,
1403
+ ]);
1404
+ let memoryRecall = null;
1314
1405
  try {
1315
- relevantMemories = this.memoryManager
1316
- ? await this.memoryManager.recallMemory(userId, task, 4, {
1317
- agentId: options.agentId || null,
1318
- })
1319
- : [];
1320
- } catch {}
1406
+ throwIfAborted(startupLink.signal, 'Sub-agent startup was aborted.');
1407
+ memoryRecall = this.memoryManager
1408
+ ? runWithAbortTimeout(
1409
+ (signal) => this.memoryManager.recallMemory(userId, task, 4, {
1410
+ agentId: options.agentId || null,
1411
+ signal,
1412
+ }),
1413
+ {
1414
+ label: 'Sub-agent memory recall',
1415
+ signal: startupLink.signal,
1416
+ },
1417
+ )
1418
+ : Promise.resolve([]);
1419
+ this.subagentStartupTasks.add(memoryRecall);
1420
+ relevantMemories = await memoryRecall;
1421
+ throwIfAborted(startupLink.signal, 'Sub-agent startup was aborted.');
1422
+ } catch (error) {
1423
+ if (isAbortError(error, startupLink.signal)) throw error;
1424
+ console.warn('[AgentEngine] Sub-agent memory recall failed:', error?.message || error);
1425
+ } finally {
1426
+ if (memoryRecall) this.subagentStartupTasks.delete(memoryRecall);
1427
+ startupLink.cleanup();
1428
+ }
1429
+
1430
+ parentRunMeta = this.getRunMeta(parentRunId);
1431
+ if (
1432
+ this.shuttingDown
1433
+ || this.lifecycleAbortController.signal.aborted
1434
+ || !parentRunMeta
1435
+ || parentRunMeta.userId !== userId
1436
+ || parentRunMeta.status !== 'running'
1437
+ || parentRunMeta.aborted === true
1438
+ ) {
1439
+ throw createAbortError(
1440
+ this.lifecycleAbortController.signal.aborted
1441
+ ? this.lifecycleAbortController.signal
1442
+ : parentRunMeta?.abortController?.signal,
1443
+ 'The parent run stopped before the sub-agent could start.',
1444
+ );
1445
+ }
1446
+ const currentSubagentCount = countExistingSubagents();
1447
+ if (currentSubagentCount >= maxSubagentsPerRun) {
1448
+ return {
1449
+ error: `This run has already spawned ${currentSubagentCount} sub-agents. The limit for one run is ${maxSubagentsPerRun}.`,
1450
+ };
1451
+ }
1452
+
1453
+ const childLink = createLinkedAbortController([
1454
+ this.lifecycleAbortController.signal,
1455
+ parentRunMeta.abortController?.signal,
1456
+ options.signal,
1457
+ ]);
1458
+ throwIfAborted(childLink.signal, 'Sub-agent startup was aborted.');
1321
1459
  const subEngine = new AgentEngine(this.io, {
1322
1460
  app: options.app || this.app,
1323
1461
  browserController: this.browserController,
@@ -1363,6 +1501,8 @@ class AgentEngine {
1363
1501
  error: null,
1364
1502
  engine: subEngine,
1365
1503
  promise: null,
1504
+ settled: false,
1505
+ deleteWhenSettled: false,
1366
1506
  };
1367
1507
  this.subagents.set(handle, record);
1368
1508
  this.emit(userId, 'run:subagent', {
@@ -1386,9 +1526,11 @@ class AgentEngine {
1386
1526
  agentId: options.agentId || null,
1387
1527
  subagentDepth: parentDepth + 1,
1388
1528
  disallowedToolNames: ['spawn_subagent'],
1529
+ signal: childLink.signal,
1389
1530
  },
1390
1531
  options.model || null
1391
1532
  );
1533
+ if (record.status === 'cancelled' || childLink.signal.aborted) return record;
1392
1534
  record.status = result.status || 'completed';
1393
1535
  let structured = null;
1394
1536
  try {
@@ -1416,16 +1558,23 @@ class AgentEngine {
1416
1558
  });
1417
1559
  return record;
1418
1560
  } catch (err) {
1419
- record.status = 'failed';
1420
- record.error = err.message;
1561
+ const cancelled = record.status === 'cancelled' || isAbortError(err, childLink.signal);
1562
+ record.status = cancelled ? 'cancelled' : 'failed';
1563
+ record.error = cancelled ? null : (err?.message || String(err));
1421
1564
  this.emit(userId, 'run:subagent', {
1422
1565
  runId: parentRunId,
1423
1566
  handle,
1424
1567
  childRunId,
1425
- status: 'failed',
1426
- error: err.message,
1568
+ status: record.status,
1569
+ error: record.error,
1427
1570
  });
1428
- throw err;
1571
+ return record;
1572
+ } finally {
1573
+ record.settled = true;
1574
+ childLink.cleanup();
1575
+ if (record.deleteWhenSettled && this.subagents.get(handle) === record) {
1576
+ this.subagents.delete(handle);
1577
+ }
1429
1578
  }
1430
1579
  })();
1431
1580
 
@@ -1562,21 +1711,43 @@ class AgentEngine {
1562
1711
  }));
1563
1712
  }
1564
1713
 
1565
- cleanupSubagentsForRun(parentRunId, options = {}) {
1566
- if (!parentRunId) return;
1714
+ async cleanupSubagentsForRun(parentRunId, options = {}) {
1715
+ if (!parentRunId) return { cancelled: 0, timedOut: 0 };
1567
1716
  const cancelRunning = options.cancelRunning !== false;
1568
- for (const [handle, record] of this.subagents.entries()) {
1717
+ const shutdowns = [];
1718
+ const records = [];
1719
+ for (const [handle, record] of Array.from(this.subagents.entries())) {
1569
1720
  if (record.parentRunId !== parentRunId) continue;
1721
+ records.push([handle, record]);
1722
+ record.deleteWhenSettled = true;
1570
1723
  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
- }
1724
+ record.status = 'cancelled';
1725
+ this.emit(record.userId, 'run:subagent', {
1726
+ runId: record.parentRunId,
1727
+ handle,
1728
+ childRunId: record.childRunId,
1729
+ status: 'cancelled',
1730
+ });
1731
+ shutdowns.push(Promise.resolve(record.engine?.shutdown?.({
1732
+ reason: 'Parent run finished before the sub-agent completed.',
1733
+ timeoutMs: Math.max(100, Number(options.timeoutMs) || 10000),
1734
+ })));
1735
+ } else if (record.status === 'running') {
1736
+ record.deleteWhenSettled = false;
1737
+ }
1738
+ }
1739
+ const results = await Promise.allSettled(shutdowns);
1740
+ for (const [handle, record] of records) {
1741
+ if ((record.settled || !record.promise) && this.subagents.get(handle) === record) {
1742
+ this.subagents.delete(handle);
1577
1743
  }
1578
- this.subagents.delete(handle);
1579
1744
  }
1745
+ return {
1746
+ cancelled: shutdowns.length,
1747
+ timedOut: results.filter((result) => (
1748
+ result.status === 'fulfilled' && result.value?.timedOut === true
1749
+ )).length,
1750
+ };
1580
1751
  }
1581
1752
 
1582
1753
  async waitForSubagent(handle, options = {}) {
@@ -1639,7 +1810,6 @@ class AgentEngine {
1639
1810
  };
1640
1811
  }
1641
1812
 
1642
- record.engine?.abort(record.childRunId);
1643
1813
  record.status = 'cancelled';
1644
1814
  this.emit(record.userId, 'run:subagent', {
1645
1815
  runId: record.parentRunId,
@@ -1648,7 +1818,15 @@ class AgentEngine {
1648
1818
  status: 'cancelled',
1649
1819
  });
1650
1820
 
1651
- return { handle, status: 'cancelled' };
1821
+ const shutdown = await record.engine?.shutdown?.({
1822
+ reason: 'Sub-agent cancelled by its parent run.',
1823
+ timeoutMs: Math.max(100, Number(options.timeoutMs) || 10000),
1824
+ });
1825
+ return {
1826
+ handle,
1827
+ status: 'cancelled',
1828
+ timedOut: shutdown?.timedOut === true,
1829
+ };
1652
1830
  }
1653
1831
 
1654
1832
  interruptRun(runId, reason = 'Server shutting down while run was in progress.') {
@@ -1665,6 +1843,7 @@ class AgentEngine {
1665
1843
  runMeta.stopReason = reason;
1666
1844
  runMeta.aborted = true;
1667
1845
  runMeta.abortController?.abort(reason);
1846
+ runMeta.resumeRun?.();
1668
1847
  this.emit(runMeta.userId, 'run:stopping', { runId });
1669
1848
  for (const pid of runMeta.toolPids) {
1670
1849
  if (this.runtimeManager && typeof this.runtimeManager.killCommand === 'function') {
@@ -1695,20 +1874,26 @@ class AgentEngine {
1695
1874
  }
1696
1875
  }
1697
1876
 
1698
- stopRun(runId) {
1877
+ stopRun(runId, reason = 'Stopped by request.') {
1878
+ const stopReason = String(reason || 'Stopped by request.').slice(0, 1000);
1699
1879
  const runMeta = this.activeRuns.get(runId);
1700
- const persistedRun = runMeta || db.prepare('SELECT user_id AS userId FROM agent_runs WHERE id = ?').get(runId);
1880
+ const persistedRun = runMeta || db.prepare(
1881
+ 'SELECT user_id AS userId, status FROM agent_runs WHERE id = ?',
1882
+ ).get(runId);
1883
+ if (!persistedRun || TERMINAL_STATUSES.has(persistedRun.status)) return false;
1701
1884
  if (persistedRun?.userId != null) {
1702
- requestRunControl(runId, persistedRun.userId, 'stop', 'Stopped by request.');
1885
+ requestRunControl(runId, persistedRun.userId, 'stop', stopReason);
1703
1886
  }
1704
1887
  const delegatedChildren = db.prepare(
1705
1888
  "SELECT child_run_id FROM agent_delegations WHERE parent_run_id = ? AND status = 'running'"
1706
1889
  ).all(runId);
1707
1890
  if (runMeta) {
1708
1891
  runMeta.status = 'stopped';
1892
+ runMeta.stopReason = stopReason;
1709
1893
  runMeta.aborted = true;
1710
- runMeta.abortController?.abort('Run stopped.');
1711
- this.emit(runMeta.userId, 'run:stopping', { runId });
1894
+ runMeta.abortController?.abort(stopReason);
1895
+ runMeta.resumeRun?.();
1896
+ this.emit(runMeta.userId, 'run:stopping', { runId, reason: stopReason });
1712
1897
  for (const pid of runMeta.toolPids) {
1713
1898
  if (this.runtimeManager && typeof this.runtimeManager.killCommand === 'function') {
1714
1899
  void this.runtimeManager.killCommand(runMeta.userId, pid, 'aborted');
@@ -1718,13 +1903,22 @@ class AgentEngine {
1718
1903
  }
1719
1904
  for (const child of delegatedChildren) {
1720
1905
  if (child.child_run_id && child.child_run_id !== runId) {
1721
- this.stopRun(child.child_run_id);
1906
+ this.stopRun(child.child_run_id, stopReason);
1722
1907
  }
1723
1908
  }
1724
1909
  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']);
1910
+ `UPDATE agent_delegations
1911
+ SET status = 'stopped', error = COALESCE(NULLIF(error, ''), ?),
1912
+ updated_at = datetime('now'), completed_at = datetime('now')
1913
+ WHERE parent_run_id = ? AND status = 'running'`
1914
+ ).run(stopReason, runId);
1915
+ closeRun(
1916
+ runId,
1917
+ 'stopped',
1918
+ { error: stopReason },
1919
+ ['running', 'pausing', 'paused', 'resuming'],
1920
+ );
1921
+ return true;
1728
1922
  }
1729
1923
 
1730
1924
  pauseRun(runId, { userId, reason = '' } = {}) {
@@ -1766,15 +1960,17 @@ class AgentEngine {
1766
1960
  return true;
1767
1961
  }
1768
1962
 
1769
- abort(runId, { userId } = {}) {
1963
+ abort(runId, { userId, reason = 'Stopped by request.' } = {}) {
1770
1964
  if (!runId) return false;
1965
+ const runMeta = this.activeRuns.get(runId);
1966
+ const persistedRun = runMeta
1967
+ || db.prepare('SELECT user_id AS userId, status FROM agent_runs WHERE id = ?').get(runId);
1968
+ if (!persistedRun || TERMINAL_STATUSES.has(persistedRun.status)) return false;
1771
1969
  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;
1970
+ // Ownership gate applies to both active and persisted runs.
1971
+ if (Number(persistedRun.userId) !== Number(userId)) return false;
1775
1972
  }
1776
- this.stopRun(runId);
1777
- return true;
1973
+ return this.stopRun(runId, reason);
1778
1974
  }
1779
1975
 
1780
1976
  abortAll(userId) {