neoagent 3.2.1-beta.2 → 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 +347 -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 +8 -41
  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 +15 -28
  52. package/server/services/ai/providers/githubCopilot.js +36 -16
  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 +13 -22
  56. package/server/services/ai/providers/nvidia.js +10 -5
  57. package/server/services/ai/providers/ollama.js +102 -82
  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 = {}) {
@@ -1172,7 +1225,8 @@ class AgentEngine {
1172
1225
  && toolExecutions.length === 0
1173
1226
  && failedStepCount === 0
1174
1227
  && !messagingSent
1175
- && Boolean(String(lastReply || '').trim());
1228
+ && Boolean(String(lastReply || '').trim())
1229
+ && !isDeferredWorkReply(lastReply);
1176
1230
  }
1177
1231
 
1178
1232
  getMessagingRetryLimit(maxIterations) {
@@ -1291,15 +1345,37 @@ class AgentEngine {
1291
1345
  }
1292
1346
 
1293
1347
  async runWithModel(userId, userMessage, options = {}, _modelOverride = null) {
1294
- return runConversation(this, userId, userMessage, options, _modelOverride);
1295
- }
1296
-
1297
- async _runWithModelInternal(userId, userMessage, options = {}, _modelOverride = null) {
1298
- 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
+ }
1299
1363
  }
1300
1364
 
1301
1365
  async spawnSubagent(userId, parentRunId, task, options = {}) {
1302
- 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
+ }
1303
1379
  const parentDepth = Math.max(0, Number(parentRunMeta?.subagentDepth) || 0);
1304
1380
  if (parentDepth >= 1) {
1305
1381
  return {
@@ -1309,24 +1385,77 @@ class AgentEngine {
1309
1385
 
1310
1386
  const aiSettings = getAiSettings(userId, options.agentId || null);
1311
1387
  const maxSubagentsPerRun = Math.max(1, Number(aiSettings.subagent_max_children_per_run) || 10);
1312
- const existingSubagents = Array.from(this.subagents.values())
1313
- .filter((record) => record.parentRunId === parentRunId);
1314
- if (existingSubagents.length >= maxSubagentsPerRun) {
1388
+ const countExistingSubagents = () => Array.from(this.subagents.values())
1389
+ .filter((record) => record.parentRunId === parentRunId).length;
1390
+ if (countExistingSubagents() >= maxSubagentsPerRun) {
1315
1391
  return {
1316
- 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}.`,
1317
1393
  };
1318
1394
  }
1319
1395
 
1320
1396
  const handle = uuidv4();
1321
1397
  const childRunId = uuidv4();
1322
1398
  let relevantMemories = [];
1399
+ const startupLink = createLinkedAbortController([
1400
+ this.lifecycleAbortController.signal,
1401
+ parentRunMeta.abortController?.signal,
1402
+ options.signal,
1403
+ ]);
1404
+ let memoryRecall = null;
1323
1405
  try {
1324
- relevantMemories = this.memoryManager
1325
- ? await this.memoryManager.recallMemory(userId, task, 4, {
1326
- agentId: options.agentId || null,
1327
- })
1328
- : [];
1329
- } 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.');
1330
1459
  const subEngine = new AgentEngine(this.io, {
1331
1460
  app: options.app || this.app,
1332
1461
  browserController: this.browserController,
@@ -1372,6 +1501,8 @@ class AgentEngine {
1372
1501
  error: null,
1373
1502
  engine: subEngine,
1374
1503
  promise: null,
1504
+ settled: false,
1505
+ deleteWhenSettled: false,
1375
1506
  };
1376
1507
  this.subagents.set(handle, record);
1377
1508
  this.emit(userId, 'run:subagent', {
@@ -1395,9 +1526,11 @@ class AgentEngine {
1395
1526
  agentId: options.agentId || null,
1396
1527
  subagentDepth: parentDepth + 1,
1397
1528
  disallowedToolNames: ['spawn_subagent'],
1529
+ signal: childLink.signal,
1398
1530
  },
1399
1531
  options.model || null
1400
1532
  );
1533
+ if (record.status === 'cancelled' || childLink.signal.aborted) return record;
1401
1534
  record.status = result.status || 'completed';
1402
1535
  let structured = null;
1403
1536
  try {
@@ -1425,16 +1558,23 @@ class AgentEngine {
1425
1558
  });
1426
1559
  return record;
1427
1560
  } catch (err) {
1428
- record.status = 'failed';
1429
- 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));
1430
1564
  this.emit(userId, 'run:subagent', {
1431
1565
  runId: parentRunId,
1432
1566
  handle,
1433
1567
  childRunId,
1434
- status: 'failed',
1435
- error: err.message,
1568
+ status: record.status,
1569
+ error: record.error,
1436
1570
  });
1437
- 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
+ }
1438
1578
  }
1439
1579
  })();
1440
1580
 
@@ -1571,21 +1711,43 @@ class AgentEngine {
1571
1711
  }));
1572
1712
  }
1573
1713
 
1574
- cleanupSubagentsForRun(parentRunId, options = {}) {
1575
- if (!parentRunId) return;
1714
+ async cleanupSubagentsForRun(parentRunId, options = {}) {
1715
+ if (!parentRunId) return { cancelled: 0, timedOut: 0 };
1576
1716
  const cancelRunning = options.cancelRunning !== false;
1577
- 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())) {
1578
1720
  if (record.parentRunId !== parentRunId) continue;
1721
+ records.push([handle, record]);
1722
+ record.deleteWhenSettled = true;
1579
1723
  if (cancelRunning && record.status === 'running') {
1580
- try {
1581
- record.engine?.abort(record.childRunId);
1582
- record.status = 'cancelled';
1583
- } catch (err) {
1584
- console.warn(`[AgentEngine] Failed to abort subagent ${handle}:`, err?.message);
1585
- }
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;
1586
1737
  }
1587
- this.subagents.delete(handle);
1588
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);
1743
+ }
1744
+ }
1745
+ return {
1746
+ cancelled: shutdowns.length,
1747
+ timedOut: results.filter((result) => (
1748
+ result.status === 'fulfilled' && result.value?.timedOut === true
1749
+ )).length,
1750
+ };
1589
1751
  }
1590
1752
 
1591
1753
  async waitForSubagent(handle, options = {}) {
@@ -1648,7 +1810,6 @@ class AgentEngine {
1648
1810
  };
1649
1811
  }
1650
1812
 
1651
- record.engine?.abort(record.childRunId);
1652
1813
  record.status = 'cancelled';
1653
1814
  this.emit(record.userId, 'run:subagent', {
1654
1815
  runId: record.parentRunId,
@@ -1657,7 +1818,15 @@ class AgentEngine {
1657
1818
  status: 'cancelled',
1658
1819
  });
1659
1820
 
1660
- 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
+ };
1661
1830
  }
1662
1831
 
1663
1832
  interruptRun(runId, reason = 'Server shutting down while run was in progress.') {
@@ -1705,21 +1874,26 @@ class AgentEngine {
1705
1874
  }
1706
1875
  }
1707
1876
 
1708
- stopRun(runId) {
1877
+ stopRun(runId, reason = 'Stopped by request.') {
1878
+ const stopReason = String(reason || 'Stopped by request.').slice(0, 1000);
1709
1879
  const runMeta = this.activeRuns.get(runId);
1710
- 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;
1711
1884
  if (persistedRun?.userId != null) {
1712
- requestRunControl(runId, persistedRun.userId, 'stop', 'Stopped by request.');
1885
+ requestRunControl(runId, persistedRun.userId, 'stop', stopReason);
1713
1886
  }
1714
1887
  const delegatedChildren = db.prepare(
1715
1888
  "SELECT child_run_id FROM agent_delegations WHERE parent_run_id = ? AND status = 'running'"
1716
1889
  ).all(runId);
1717
1890
  if (runMeta) {
1718
1891
  runMeta.status = 'stopped';
1892
+ runMeta.stopReason = stopReason;
1719
1893
  runMeta.aborted = true;
1720
- runMeta.abortController?.abort('Run stopped.');
1894
+ runMeta.abortController?.abort(stopReason);
1721
1895
  runMeta.resumeRun?.();
1722
- this.emit(runMeta.userId, 'run:stopping', { runId });
1896
+ this.emit(runMeta.userId, 'run:stopping', { runId, reason: stopReason });
1723
1897
  for (const pid of runMeta.toolPids) {
1724
1898
  if (this.runtimeManager && typeof this.runtimeManager.killCommand === 'function') {
1725
1899
  void this.runtimeManager.killCommand(runMeta.userId, pid, 'aborted');
@@ -1729,13 +1903,22 @@ class AgentEngine {
1729
1903
  }
1730
1904
  for (const child of delegatedChildren) {
1731
1905
  if (child.child_run_id && child.child_run_id !== runId) {
1732
- this.stopRun(child.child_run_id);
1906
+ this.stopRun(child.child_run_id, stopReason);
1733
1907
  }
1734
1908
  }
1735
1909
  db.prepare(
1736
- "UPDATE agent_delegations SET status = 'stopped', updated_at = datetime('now'), completed_at = datetime('now') WHERE parent_run_id = ? AND status = 'running'"
1737
- ).run(runId);
1738
- 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;
1739
1922
  }
1740
1923
 
1741
1924
  pauseRun(runId, { userId, reason = '' } = {}) {
@@ -1777,15 +1960,17 @@ class AgentEngine {
1777
1960
  return true;
1778
1961
  }
1779
1962
 
1780
- abort(runId, { userId } = {}) {
1963
+ abort(runId, { userId, reason = 'Stopped by request.' } = {}) {
1781
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;
1782
1969
  if (userId != null) {
1783
- // Ownership gate: never let one user abort another user's active run.
1784
- const runMeta = this.activeRuns.get(runId);
1785
- 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;
1786
1972
  }
1787
- this.stopRun(runId);
1788
- return true;
1973
+ return this.stopRun(runId, reason);
1789
1974
  }
1790
1975
 
1791
1976
  abortAll(userId) {