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
@@ -0,0 +1,108 @@
1
+ 'use strict';
2
+
3
+ const db = require('../../../db/database');
4
+
5
+ const TERMINAL_STATUSES = new Set(['completed', 'failed', 'stopped', 'interrupted']);
6
+ const CONTROL_PRIORITY = Object.freeze({ pause: 1, stop: 2, interrupt: 3 });
7
+
8
+ function getRunControl(runId) {
9
+ return db.prepare(
10
+ `SELECT action, reason, requested_at
11
+ FROM agent_run_controls
12
+ WHERE run_id = ? AND consumed_at IS NULL`,
13
+ ).get(runId) || null;
14
+ }
15
+
16
+ function requestRunControl(runId, userId, action, reason = '') {
17
+ if (!Object.hasOwn(CONTROL_PRIORITY, action)) {
18
+ throw new Error(`Unsupported run control action: ${action}`);
19
+ }
20
+ const run = db.prepare(
21
+ 'SELECT status FROM agent_runs WHERE id = ? AND user_id = ?',
22
+ ).get(runId, userId);
23
+ if (!run) return { accepted: false, reason: 'not_found' };
24
+ if (TERMINAL_STATUSES.has(run.status)) return { accepted: false, reason: 'terminal', status: run.status };
25
+
26
+ const existing = getRunControl(runId);
27
+ if (existing && CONTROL_PRIORITY[existing.action] > CONTROL_PRIORITY[action]) {
28
+ return { accepted: false, reason: 'stronger_signal_pending', action: existing.action };
29
+ }
30
+ db.prepare(
31
+ `INSERT INTO agent_run_controls (run_id, user_id, action, reason)
32
+ VALUES (?, ?, ?, ?)
33
+ ON CONFLICT(run_id) DO UPDATE SET
34
+ action = excluded.action,
35
+ reason = excluded.reason,
36
+ requested_at = datetime('now'),
37
+ consumed_at = NULL`,
38
+ ).run(runId, userId, action, String(reason || '').slice(0, 1000));
39
+ return { accepted: true, action };
40
+ }
41
+
42
+ function checkpointRun(runId, phase, state = {}) {
43
+ db.prepare(
44
+ `INSERT INTO agent_run_checkpoints (run_id, version, phase, state_json)
45
+ VALUES (?, 1, ?, ?)
46
+ ON CONFLICT(run_id) DO UPDATE SET
47
+ version = 1,
48
+ phase = excluded.phase,
49
+ state_json = excluded.state_json,
50
+ updated_at = datetime('now')`,
51
+ ).run(runId, phase, JSON.stringify(state));
52
+ }
53
+
54
+ function transitionRun(runId, status, fields = {}, allowed = ['running']) {
55
+ const assignments = ['status = ?', "updated_at = datetime('now')"];
56
+ const values = [status];
57
+ if (Object.hasOwn(fields, 'error')) {
58
+ assignments.push('error = ?');
59
+ values.push(fields.error || null);
60
+ }
61
+ if (Object.hasOwn(fields, 'finalResponse')) {
62
+ assignments.push('final_response = ?');
63
+ values.push(fields.finalResponse || null);
64
+ }
65
+ if (Object.hasOwn(fields, 'totalTokens')) {
66
+ assignments.push('total_tokens = ?');
67
+ values.push(Number(fields.totalTokens) || 0);
68
+ }
69
+ if (TERMINAL_STATUSES.has(status)) assignments.push("completed_at = COALESCE(completed_at, datetime('now'))");
70
+ const placeholders = allowed.map(() => '?').join(', ');
71
+ values.push(runId, ...allowed);
72
+ const result = db.prepare(
73
+ `UPDATE agent_runs SET ${assignments.join(', ')}
74
+ WHERE id = ? AND status IN (${placeholders})`,
75
+ ).run(...values);
76
+ return result.changes > 0;
77
+ }
78
+
79
+ function closeRun(runId, status, fields = {}, allowed = ['running']) {
80
+ const transaction = db.transaction(() => {
81
+ if (!transitionRun(runId, status, fields, allowed)) return false;
82
+ db.prepare(
83
+ `UPDATE agent_steps
84
+ SET status = ?, error = COALESCE(NULLIF(error, ''), ?), completed_at = COALESCE(completed_at, datetime('now'))
85
+ WHERE run_id = ? AND status = 'running'`,
86
+ ).run(status, fields.error || null, runId);
87
+ db.prepare(
88
+ `UPDATE agent_delegations
89
+ SET status = ?, error = COALESCE(NULLIF(error, ''), ?), updated_at = datetime('now'), completed_at = COALESCE(completed_at, datetime('now'))
90
+ WHERE parent_run_id = ? AND status = 'running'`,
91
+ ).run(status, fields.error || null, runId);
92
+ db.prepare(
93
+ `UPDATE agent_run_controls SET consumed_at = datetime('now')
94
+ WHERE run_id = ? AND consumed_at IS NULL`,
95
+ ).run(runId);
96
+ return true;
97
+ });
98
+ return transaction();
99
+ }
100
+
101
+ module.exports = {
102
+ TERMINAL_STATUSES,
103
+ checkpointRun,
104
+ closeRun,
105
+ getRunControl,
106
+ requestRunControl,
107
+ transitionRun,
108
+ };
@@ -6,7 +6,12 @@ const {
6
6
  const {
7
7
  normalizeOutgoingMessage,
8
8
  } = require('../messagingFallback');
9
- const { withProviderRetry, isTransientError } = require('../providerRetry');
9
+ const { withProviderRetry } = require('../providerRetry');
10
+ const {
11
+ abortableDelay,
12
+ getErrorCode,
13
+ getHttpStatus,
14
+ } = require('../../../utils/retry');
10
15
  const { shortenRunId, summarizeForLog } = require('../logFormat');
11
16
  const {
12
17
  TICK_MS,
@@ -16,6 +21,15 @@ const {
16
21
  evaluateProgressLiveness,
17
22
  } = require('./progress_monitor');
18
23
 
24
+ const SAFE_PRE_DELIVERY_NETWORK_CODES = new Set([
25
+ 'EAI_AGAIN',
26
+ 'ENOTFOUND',
27
+ 'ECONNREFUSED',
28
+ 'ENETUNREACH',
29
+ 'EHOSTUNREACH',
30
+ 'UND_ERR_CONNECT_TIMEOUT',
31
+ ]);
32
+
19
33
  function isoNow() {
20
34
  return new Date().toISOString();
21
35
  }
@@ -39,9 +53,26 @@ function requireSuccessfulMessagingDelivery(result, label = 'Messaging delivery'
39
53
  const error = new Error(`${label} failed: ${reason}`);
40
54
  error.code = 'MESSAGING_DELIVERY_FAILED';
41
55
  error.deliveryResult = result || null;
56
+ error.safeToRetry = result?.safeToRetry === true;
57
+ error.deliveryAmbiguous = result?.deliveryAmbiguous === true;
42
58
  throw error;
43
59
  }
44
60
 
61
+ function isSafeMessagingDeliveryRetry(error) {
62
+ if (!error || error.retryable === false || error.deliveryAmbiguous === true) return false;
63
+ if (error.safeToRetry === true) return true;
64
+ const result = error.deliveryResult;
65
+ if (result?.deliveryAmbiguous === true || result?.retryable === false) return false;
66
+ if (result?.success === false) return true;
67
+
68
+ // A rate-limit response explicitly rejected the request. DNS/connect failures
69
+ // happen before a platform can accept it. Timeouts, resets, broken pipes, and
70
+ // generic 5xx responses are intentionally excluded because the message may
71
+ // already have been accepted and retrying would duplicate it.
72
+ if (getHttpStatus(error) === 429) return true;
73
+ return SAFE_PRE_DELIVERY_NETWORK_CODES.has(String(getErrorCode(error) || ''));
74
+ }
75
+
45
76
  // Harness-driven progress: the originating chat must see autonomous updates during
46
77
  // long runs even when a weak model never calls send_interim_update itself. The
47
78
  // supervisor paces this call (FIRST/REPEAT thresholds + a repeat guard). The update
@@ -66,13 +97,30 @@ async function sendRuntimeMessagingHeartbeat(engine, runId, options = {}) {
66
97
  && typeof runMeta.composeProgressUpdate === 'function') {
67
98
  let statusMsg = '';
68
99
  try {
69
- const composed = await runMeta.composeProgressUpdate({ stalled: options.stalled === true });
100
+ const composed = await runMeta.composeProgressUpdate({
101
+ stalled: options.stalled === true,
102
+ signal: runMeta.messagingProgressSupervisor?.abortController?.signal || null,
103
+ });
70
104
  if (normalizeOutgoingMessage(composed, platform)) statusMsg = String(composed).trim();
71
105
  } catch { /* no dynamic update available this tick */ }
106
+ const currentRunMeta = engine.getRunMeta(runId);
107
+ if (
108
+ currentRunMeta !== runMeta
109
+ || runMeta.aborted
110
+ || runMeta.status !== 'running'
111
+ || runMeta.terminalInterim
112
+ || runMeta.finalDeliverySent
113
+ || runMeta.deliveryState?.finalContentDelivered === true
114
+ ) {
115
+ return { sent: false, skipped: true, terminal: true };
116
+ }
72
117
  if (statusMsg) try {
73
118
  const delivery = await engine.messagingManager.sendMessage(runMeta.userId, platform, chatId, statusMsg, {
74
119
  runId,
75
120
  agentId: runMeta.agentId,
121
+ signal: runMeta.messagingProgressSupervisor?.abortController?.signal
122
+ || runMeta.abortController?.signal
123
+ || null,
76
124
  });
77
125
  if (delivery?.success === true && delivery?.suppressed !== true) {
78
126
  const nowIso = isoNow();
@@ -157,11 +205,12 @@ async function deliverMessagingFinalFallback(engine, {
157
205
  console.info(
158
206
  `[Run ${shortenRunId(runId)}] messaging_fallback chunks=${chunks.length} to=${summarizeForLog(chatId, 80)}`
159
207
  );
208
+ const deliveredChunks = [];
160
209
  for (let i = 0; i < chunks.length; i++) {
161
210
  if (i > 0) {
162
211
  const delay = Math.max(1000, Math.min(chunks[i].length * 30, 4000));
163
212
  await engine.messagingManager.sendTyping(userId, platform, chatId, true, { agentId }).catch(() => {});
164
- await new Promise((resolve) => setTimeout(resolve, delay));
213
+ await abortableDelay(delay, runMeta.abortController?.signal || null);
165
214
  }
166
215
  try {
167
216
  await withProviderRetry(async () => {
@@ -170,32 +219,34 @@ async function deliverMessagingFinalFallback(engine, {
170
219
  platform,
171
220
  chatId,
172
221
  chunks[i],
173
- { runId, agentId },
222
+ {
223
+ runId,
224
+ agentId,
225
+ idempotencyKey: `${runId}:final:${i}`,
226
+ signal: runMeta.abortController?.signal || null,
227
+ },
174
228
  );
175
229
  return requireSuccessfulMessagingDelivery(deliveryResult, 'Final messaging delivery');
176
230
  }, {
177
231
  ...engine.messagingDeliveryRetry,
178
232
  label: `MessagingDelivery ${platform}`,
179
- isRetryable: (error) => (
180
- error?.retryable !== false
181
- && (
182
- error?.code === 'MESSAGING_DELIVERY_FAILED'
183
- || isTransientError(error)
184
- )
185
- ),
233
+ signal: runMeta.abortController?.signal || null,
234
+ isRetryable: isSafeMessagingDeliveryRetry,
186
235
  });
187
236
  } catch (error) {
188
237
  error.disableAutonomousRetry = true;
238
+ error.deliveredChunks = deliveredChunks.slice();
189
239
  throw error;
190
240
  }
241
+ deliveredChunks.push(chunks[i]);
242
+ runMeta.lastSentMessage = chunks[i];
243
+ if (!Array.isArray(runMeta.sentMessages)) runMeta.sentMessages = [];
244
+ runMeta.sentMessages.push(chunks[i]);
191
245
  }
192
246
 
193
- runMeta.lastSentMessage = chunks[chunks.length - 1] || cleanedContent;
194
- runMeta.sentMessages = Array.isArray(runMeta.sentMessages)
195
- ? [...runMeta.sentMessages, ...chunks]
196
- : chunks.slice();
247
+ runMeta.lastSentMessage = deliveredChunks[deliveredChunks.length - 1] || cleanedContent;
197
248
  engine.markRunFinalDelivery(runId, runMeta.lastSentMessage);
198
- return { sent: true, chunks };
249
+ return { sent: true, chunks: deliveredChunks };
199
250
  }
200
251
 
201
252
  async function tickMessagingProgressSupervisor(engine, runId) {
@@ -206,54 +257,71 @@ async function tickMessagingProgressSupervisor(engine, runId) {
206
257
  if (runMeta.terminalInterim) {
207
258
  return { sent: false, skipped: true };
208
259
  }
260
+ if (runMeta.progressSupervisorTickInFlight === true) {
261
+ return { sent: false, skipped: true, inFlight: true };
262
+ }
263
+ runMeta.progressSupervisorTickInFlight = true;
264
+ let settleTick;
265
+ const tickPromise = new Promise((resolve) => { settleTick = resolve; });
266
+ runMeta.progressSupervisorTickPromise = tickPromise;
209
267
 
210
- const ledger = runMeta.progressLedger || buildInitialProgressLedger({
211
- startedAt: runMeta.startedAtIso || isoNow(),
212
- });
213
- runMeta.progressLedger = ledger;
214
- const liveness = evaluateProgressLiveness(runMeta);
215
- const stalled = liveness.stalled;
216
-
217
- if (stalled && !ledger.stallNotifiedAt) {
218
- engine.updateRunProgress(runId, {
219
- stallNotifiedAt: isoNow(),
220
- progressState: 'stalled',
268
+ try {
269
+ const ledger = runMeta.progressLedger || buildInitialProgressLedger({
270
+ startedAt: runMeta.startedAtIso || isoNow(),
221
271
  });
222
- engine.recordRunEvent(runMeta.userId, runId, 'progress_stalled', {
223
- currentTool: ledger.currentTool || null,
224
- currentStep: ledger.currentStep || null,
225
- phase: ledger.currentPhase || 'idle',
226
- }, { agentId: runMeta.agentId });
227
- }
272
+ runMeta.progressLedger = ledger;
273
+ const liveness = evaluateProgressLiveness(runMeta);
274
+ const stalled = liveness.stalled;
228
275
 
229
- if (!liveness.shouldNudge) {
230
- return { sent: false, skipped: true };
231
- }
276
+ if (stalled && !ledger.stallNotifiedAt) {
277
+ engine.updateRunProgress(runId, {
278
+ stallNotifiedAt: isoNow(),
279
+ progressState: 'stalled',
280
+ });
281
+ engine.recordRunEvent(runMeta.userId, runId, 'progress_stalled', {
282
+ currentTool: ledger.currentTool || null,
283
+ currentStep: ledger.currentStep || null,
284
+ phase: ledger.currentPhase || 'idle',
285
+ }, { agentId: runMeta.agentId });
286
+ }
232
287
 
233
- const lastSupervisorNudgeAtMs = timestampMs(runMeta.lastSupervisorNudgeAt, 0);
234
- if (lastSupervisorNudgeAtMs > 0 && (Date.now() - lastSupervisorNudgeAtMs) < REPEAT_UPDATE_MS) {
235
- return { sent: false, skipped: true };
236
- }
288
+ if (!liveness.shouldNudge) {
289
+ return { sent: false, skipped: true };
290
+ }
237
291
 
238
- if (ledger.currentPhase === 'tool' || ledger.currentPhase === 'model') {
239
- return sendRuntimeMessagingHeartbeat(engine, runId, { stalled });
240
- }
292
+ const lastSupervisorNudgeAtMs = timestampMs(runMeta.lastSupervisorNudgeAt, 0);
293
+ if (lastSupervisorNudgeAtMs > 0 && (Date.now() - lastSupervisorNudgeAtMs) < REPEAT_UPDATE_MS) {
294
+ return { sent: false, skipped: true };
295
+ }
241
296
 
242
- if (ledger.currentPhase !== 'idle') {
243
- return { sent: false, skipped: true };
244
- }
297
+ if (ledger.currentPhase === 'tool' || ledger.currentPhase === 'model') {
298
+ return await sendRuntimeMessagingHeartbeat(engine, runId, { stalled });
299
+ }
245
300
 
246
- const queued = engine.enqueueSystemSteering(runId, buildProgressNudge({ stalled }), {
247
- reason: stalled ? 'stalled_progress_check' : 'progress_check',
248
- });
249
- if (!queued) {
250
- return { sent: false, skipped: true };
301
+ if (ledger.currentPhase !== 'idle') {
302
+ return { sent: false, skipped: true };
303
+ }
304
+
305
+ const queued = engine.enqueueSystemSteering(runId, buildProgressNudge({ stalled }), {
306
+ reason: stalled ? 'stalled_progress_check' : 'progress_check',
307
+ });
308
+ if (!queued) {
309
+ return { sent: false, skipped: true };
310
+ }
311
+ runMeta.lastSupervisorNudgeAt = isoNow();
312
+ engine.updateRunProgress(runId, {
313
+ lastUserVisibleUpdateAt: ledger.lastUserVisibleUpdateAt || null,
314
+ });
315
+ return { sent: false, queued: true };
316
+ } finally {
317
+ settleTick();
318
+ if (engine.getRunMeta(runId) === runMeta) {
319
+ runMeta.progressSupervisorTickInFlight = false;
320
+ if (runMeta.progressSupervisorTickPromise === tickPromise) {
321
+ runMeta.progressSupervisorTickPromise = null;
322
+ }
323
+ }
251
324
  }
252
- runMeta.lastSupervisorNudgeAt = isoNow();
253
- engine.updateRunProgress(runId, {
254
- lastUserVisibleUpdateAt: ledger.lastUserVisibleUpdateAt || null,
255
- });
256
- return { sent: false, queued: true };
257
325
  }
258
326
 
259
327
  function startMessagingProgressSupervisor(engine, runId) {
@@ -264,25 +332,29 @@ function startMessagingProgressSupervisor(engine, runId) {
264
332
  if (runMeta.messagingProgressSupervisor?.timer) {
265
333
  return true;
266
334
  }
335
+ const abortController = new AbortController();
267
336
  const timer = setInterval(() => {
268
337
  tickMessagingProgressSupervisor(engine, runId).catch((error) => {
269
338
  console.warn('[Engine] Messaging progress supervisor failed:', error?.message || error);
270
339
  });
271
340
  }, TICK_MS);
272
341
  timer.unref?.();
273
- runMeta.messagingProgressSupervisor = { timer };
342
+ runMeta.messagingProgressSupervisor = { timer, abortController };
274
343
  return true;
275
344
  }
276
345
 
277
346
  function stopMessagingProgressSupervisor(engine, runId) {
278
347
  const runMeta = engine.getRunMeta(runId);
348
+ const inFlight = runMeta?.progressSupervisorTickPromise || null;
279
349
  const timer = runMeta?.messagingProgressSupervisor?.timer || null;
280
350
  if (timer) {
281
351
  clearInterval(timer);
282
352
  }
353
+ runMeta?.messagingProgressSupervisor?.abortController?.abort('Progress supervisor stopped.');
283
354
  if (runMeta?.messagingProgressSupervisor) {
284
355
  runMeta.messagingProgressSupervisor = null;
285
356
  }
357
+ return inFlight || Promise.resolve();
286
358
  }
287
359
 
288
360
  module.exports = {
@@ -0,0 +1,91 @@
1
+ 'use strict';
2
+
3
+ const MODEL_CALL_TIMEOUT_MS = 5 * 60 * 1000;
4
+
5
+ function formatElapsedDuration(durationMs) {
6
+ const totalSeconds = Math.max(1, Math.floor(Number(durationMs || 0) / 1000));
7
+ if (totalSeconds < 60) return `${totalSeconds}s`;
8
+ const minutes = Math.floor(totalSeconds / 60);
9
+ const seconds = totalSeconds % 60;
10
+ if (seconds === 0) return `${minutes}m`;
11
+ return `${minutes}m ${seconds}s`;
12
+ }
13
+
14
+ function resolveModelCallTimeoutMs(options = {}) {
15
+ const requested = Number(options?.modelCallTimeoutMs);
16
+ if (Number.isFinite(requested) && requested > 0) {
17
+ return Math.max(10, requested);
18
+ }
19
+ return MODEL_CALL_TIMEOUT_MS;
20
+ }
21
+
22
+ function abortError(reason = null) {
23
+ if (reason instanceof Error) return reason;
24
+ const error = new Error(String(reason || 'Model call aborted.'));
25
+ error.name = 'AbortError';
26
+ error.code = 'ABORT_ERR';
27
+ return error;
28
+ }
29
+
30
+ async function withModelCallTimeout(promise, options = {}, label = 'Model call') {
31
+ const timeoutMs = resolveModelCallTimeoutMs(options);
32
+ const signal = options?.modelAbortController?.signal || options?.signal || null;
33
+ let timer = null;
34
+ let abortListener = null;
35
+ const timeout = new Promise((_, reject) => {
36
+ timer = setTimeout(() => {
37
+ const error = new Error(`${label} timed out after ${formatElapsedDuration(timeoutMs)}.`);
38
+ error.code = 'MODEL_CALL_TIMEOUT';
39
+ options?.modelAbortController?.abort(error);
40
+ reject(error);
41
+ }, timeoutMs);
42
+ });
43
+ const aborted = new Promise((_, reject) => {
44
+ if (!signal) return;
45
+ abortListener = () => reject(abortError(signal.reason));
46
+ if (signal.aborted) abortListener();
47
+ else signal.addEventListener('abort', abortListener, { once: true });
48
+ });
49
+
50
+ try {
51
+ return await Promise.race([Promise.resolve(promise), timeout, aborted]);
52
+ } finally {
53
+ if (timer) clearTimeout(timer);
54
+ if (abortListener) signal?.removeEventListener('abort', abortListener);
55
+ }
56
+ }
57
+
58
+ async function runAbortableModelCall(factory, options = {}, label = 'Model call') {
59
+ const modelAbortController = new AbortController();
60
+ const parentSignals = [...new Set([
61
+ options?.signal,
62
+ ...(Array.isArray(options?.signals) ? options.signals : []),
63
+ ].filter((signal) => signal && typeof signal.addEventListener === 'function'))];
64
+ const parentListeners = new Map();
65
+ for (const parentSignal of parentSignals) {
66
+ const abortFromParent = () => modelAbortController.abort(parentSignal.reason);
67
+ parentListeners.set(parentSignal, abortFromParent);
68
+ if (parentSignal.aborted) abortFromParent();
69
+ else parentSignal.addEventListener('abort', abortFromParent, { once: true });
70
+ }
71
+
72
+ try {
73
+ const promise = Promise.resolve().then(() => factory(modelAbortController.signal));
74
+ return await withModelCallTimeout(
75
+ promise,
76
+ { ...options, modelAbortController },
77
+ label,
78
+ );
79
+ } finally {
80
+ for (const [parentSignal, abortFromParent] of parentListeners) {
81
+ parentSignal.removeEventListener('abort', abortFromParent);
82
+ }
83
+ }
84
+ }
85
+
86
+ module.exports = {
87
+ abortError,
88
+ resolveModelCallTimeoutMs,
89
+ runAbortableModelCall,
90
+ withModelCallTimeout,
91
+ };
@@ -5,47 +5,16 @@ const { sanitizeModelOutput } = require('../outputSanitizer');
5
5
  const { parseJsonObject } = require('../taskAnalysis');
6
6
  const { withProviderRetry, isTransientError } = require('../providerRetry');
7
7
  const { normalizeUsage, recordModelUsage } = require('../usage');
8
-
9
- const MODEL_CALL_TIMEOUT_MS = 5 * 60 * 1000;
8
+ const {
9
+ resolveModelCallTimeoutMs,
10
+ runAbortableModelCall,
11
+ withModelCallTimeout,
12
+ } = require('./model_call_guard');
10
13
 
11
14
  function isoNow() {
12
15
  return new Date().toISOString();
13
16
  }
14
17
 
15
- function formatElapsedDuration(durationMs) {
16
- const totalSeconds = Math.max(1, Math.floor(Number(durationMs || 0) / 1000));
17
- if (totalSeconds < 60) return `${totalSeconds}s`;
18
- const minutes = Math.floor(totalSeconds / 60);
19
- const seconds = totalSeconds % 60;
20
- if (seconds === 0) return `${minutes}m`;
21
- return `${minutes}m ${seconds}s`;
22
- }
23
-
24
- function resolveModelCallTimeoutMs(options = {}) {
25
- const requested = Number(options?.modelCallTimeoutMs);
26
- if (Number.isFinite(requested) && requested > 0) {
27
- return Math.max(10, requested);
28
- }
29
- return MODEL_CALL_TIMEOUT_MS;
30
- }
31
-
32
- async function withModelCallTimeout(promise, options = {}, label = 'Model call') {
33
- const timeoutMs = resolveModelCallTimeoutMs(options);
34
- let timer = null;
35
- const timeout = new Promise((_, reject) => {
36
- timer = setTimeout(() => {
37
- const error = new Error(`${label} timed out after ${formatElapsedDuration(timeoutMs)}.`);
38
- error.code = 'MODEL_CALL_TIMEOUT';
39
- reject(error);
40
- }, timeoutMs);
41
- });
42
- try {
43
- return await Promise.race([Promise.resolve(promise), timeout]);
44
- } finally {
45
- if (timer) clearTimeout(timer);
46
- }
47
- }
48
-
49
18
  async function requestStructuredJson(engine, {
50
19
  provider,
51
20
  providerName,
@@ -61,6 +30,11 @@ async function requestStructuredJson(engine, {
61
30
  }) {
62
31
  const startedAt = Date.now();
63
32
  const structuredStep = `model:${phase}`;
33
+ const modelAbortController = new AbortController();
34
+ const parentSignal = telemetry?.signal;
35
+ const abortFromParent = () => modelAbortController.abort(parentSignal?.reason);
36
+ if (parentSignal?.aborted) abortFromParent();
37
+ else parentSignal?.addEventListener('abort', abortFromParent, { once: true });
64
38
  if (telemetry?.runId) {
65
39
  engine.updateRunProgress(telemetry.runId, {
66
40
  currentPhase: 'model',
@@ -70,7 +44,6 @@ async function requestStructuredJson(engine, {
70
44
  });
71
45
  }
72
46
 
73
- let completed = false;
74
47
  try {
75
48
  const response = await withProviderRetry(
76
49
  () => withModelCallTimeout(
@@ -84,14 +57,18 @@ async function requestStructuredJson(engine, {
84
57
  model,
85
58
  maxTokens,
86
59
  reasoningEffort: reasoningEffort || engine.getReasoningEffort(providerName, {}),
60
+ signal: modelAbortController.signal,
87
61
  }
88
62
  ),
89
- telemetry || {},
63
+ { ...(telemetry || {}), modelAbortController },
90
64
  `${phase} model call`,
91
65
  ),
92
- { label: `Engine ${model} (structured)` }
66
+ {
67
+ label: `Engine ${model} (structured)`,
68
+ isRetryable: (err) => !modelAbortController.signal.aborted && isTransientError(err),
69
+ signal: modelAbortController.signal,
70
+ }
93
71
  );
94
- completed = true;
95
72
  if (telemetry?.runId && telemetry?.userId) {
96
73
  recordModelUsage({
97
74
  runId: telemetry.runId,
@@ -114,6 +91,7 @@ async function requestStructuredJson(engine, {
114
91
  usage: normalizedUsage?.totalTokens || 0,
115
92
  };
116
93
  } finally {
94
+ parentSignal?.removeEventListener('abort', abortFromParent);
117
95
  const runMeta = telemetry?.runId ? engine.getRunMeta(telemetry.runId) : null;
118
96
  if (runMeta?.progressLedger?.currentStep === structuredStep) {
119
97
  engine.updateRunProgress(telemetry.runId, {
@@ -121,8 +99,6 @@ async function requestStructuredJson(engine, {
121
99
  currentStep: null,
122
100
  currentTool: null,
123
101
  currentStepStartedAt: null,
124
- }, {
125
- verified: completed,
126
102
  });
127
103
  }
128
104
  }
@@ -140,9 +116,15 @@ async function requestModelResponse(engine, {
140
116
  }) {
141
117
  const startedAt = Date.now();
142
118
  const requestMessages = sanitizeConversationMessages(messages);
119
+ const modelAbortController = new AbortController();
120
+ const parentSignal = options.signal;
121
+ const abortFromParent = () => modelAbortController.abort(parentSignal?.reason);
122
+ if (parentSignal?.aborted) abortFromParent();
123
+ else parentSignal?.addEventListener('abort', abortFromParent, { once: true });
143
124
  const callOptions = {
144
125
  model,
145
126
  reasoningEffort: engine.getReasoningEffort(providerName, options),
127
+ signal: modelAbortController.signal,
146
128
  };
147
129
 
148
130
  const attemptModelCall = async () => {
@@ -157,7 +139,7 @@ async function requestModelResponse(engine, {
157
139
  while (true) {
158
140
  const next = await withModelCallTimeout(
159
141
  iterator.next(),
160
- options,
142
+ { ...options, modelAbortController },
161
143
  `Model stream iteration ${iteration}`,
162
144
  );
163
145
  if (next.done) break;
@@ -194,7 +176,7 @@ async function requestModelResponse(engine, {
194
176
  } else {
195
177
  response = await withModelCallTimeout(
196
178
  provider.chat(requestMessages, tools, callOptions),
197
- options,
179
+ { ...options, modelAbortController },
198
180
  `Model iteration ${iteration}`,
199
181
  );
200
182
  }
@@ -202,18 +184,27 @@ async function requestModelResponse(engine, {
202
184
  return { response, streamContent };
203
185
  };
204
186
 
205
- const { response, streamContent } = await withProviderRetry(attemptModelCall, {
206
- ...(options.retry || {}),
207
- label: `Engine ${model}`,
208
- isRetryable: (err) => !err?.__providerRetryUnsafe && isTransientError(err),
209
- onRetry: ({ attempt, delayMs }) => {
210
- engine.emit(options.userId, 'run:interim', {
211
- runId,
212
- message: `Model service busy; retrying (attempt ${attempt}) in ${Math.max(1, Math.round(delayMs / 1000))}s.`,
213
- phase: 'recovering',
214
- });
215
- },
216
- });
187
+ let response;
188
+ let streamContent;
189
+ try {
190
+ ({ response, streamContent } = await withProviderRetry(attemptModelCall, {
191
+ ...(options.retry || {}),
192
+ label: `Engine ${model}`,
193
+ isRetryable: (err) => !modelAbortController.signal.aborted
194
+ && !err?.__providerRetryUnsafe
195
+ && isTransientError(err),
196
+ onRetry: ({ attempt, delayMs }) => {
197
+ engine.emit(options.userId, 'run:interim', {
198
+ runId,
199
+ message: `Model service busy; retrying (attempt ${attempt}) in ${Math.max(1, Math.round(delayMs / 1000))}s.`,
200
+ phase: 'recovering',
201
+ });
202
+ },
203
+ signal: modelAbortController.signal,
204
+ }));
205
+ } finally {
206
+ parentSignal?.removeEventListener('abort', abortFromParent);
207
+ }
217
208
 
218
209
  const resolvedResponse = response || {
219
210
  content: streamContent,
@@ -254,5 +245,6 @@ module.exports = {
254
245
  requestModelResponse,
255
246
  requestStructuredJson,
256
247
  resolveModelCallTimeoutMs,
248
+ runAbortableModelCall,
257
249
  withModelCallTimeout,
258
250
  };