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
@@ -1,7 +1,8 @@
1
1
  'use strict';
2
2
 
3
3
  const { describeEnvStatus, resolveSpotifyOAuthConfig } = require('../env');
4
- const { appendQuery, createOAuthProvider } = require('../oauth_provider');
4
+ const { appendQuery, createOAuthProvider, fetchJson } = require('../oauth_provider');
5
+ const { fetchResponseText } = require('../http');
5
6
 
6
7
  const SPOTIFY_APPS = [
7
8
  {
@@ -121,33 +122,32 @@ function spotifyUrl(path, query) {
121
122
  return url.toString();
122
123
  }
123
124
 
124
- async function refreshSpotifyAccessToken(config, credentials) {
125
+ async function refreshSpotifyAccessToken(config, credentials, signal = null) {
125
126
  const refreshToken = String(credentials?.refresh_token || '').trim();
126
127
  if (!refreshToken) {
127
128
  return credentials;
128
129
  }
129
130
  const basic = Buffer.from(`${config.clientId}:${config.clientSecret}`).toString('base64');
130
- const response = await fetch('https://accounts.spotify.com/api/token', {
131
- method: 'POST',
132
- headers: {
133
- Authorization: `Basic ${basic}`,
134
- 'Content-Type': 'application/x-www-form-urlencoded',
135
- Accept: 'application/json',
131
+ const data = await fetchJson(
132
+ 'https://accounts.spotify.com/api/token',
133
+ {
134
+ method: 'POST',
135
+ headers: { Authorization: `Basic ${basic}` },
136
+ form: {
137
+ grant_type: 'refresh_token',
138
+ refresh_token: refreshToken,
139
+ },
140
+ signal,
136
141
  },
137
- body: new URLSearchParams({
138
- grant_type: 'refresh_token',
139
- refresh_token: refreshToken,
140
- }).toString(),
141
- });
142
- const data = await response.json().catch(() => ({}));
143
- if (!response.ok) {
144
- const message = data?.error_description || data?.error || `${response.status} ${response.statusText}`;
145
- throw new Error(`Spotify token refresh failed: ${message}`);
142
+ { serviceName: 'Spotify token refresh' },
143
+ );
144
+ if (!String(data?.access_token || '').trim()) {
145
+ throw new Error('Spotify token refresh did not return an access token.');
146
146
  }
147
147
  const expiresIn = Number(data?.expires_in) || 3600;
148
148
  return {
149
149
  ...credentials,
150
- access_token: data?.access_token || credentials.access_token,
150
+ access_token: data.access_token,
151
151
  refresh_token: data?.refresh_token || refreshToken,
152
152
  token_type: data?.token_type || credentials.token_type || 'Bearer',
153
153
  scope: data?.scope || credentials.scope || '',
@@ -156,7 +156,7 @@ async function refreshSpotifyAccessToken(config, credentials) {
156
156
  };
157
157
  }
158
158
 
159
- async function ensureSpotifyAccessToken(config, credentials) {
159
+ async function ensureSpotifyAccessToken(config, credentials, signal = null) {
160
160
  const accessToken = String(credentials?.access_token || '').trim();
161
161
  if (!accessToken) {
162
162
  throw new Error('Spotify access token is missing. Reconnect this integration account.');
@@ -165,32 +165,36 @@ async function ensureSpotifyAccessToken(config, credentials) {
165
165
  const expiresAt = Date.parse(String(credentials?.expires_at || ''));
166
166
  const isNearExpiry = Number.isFinite(expiresAt) && expiresAt <= Date.now() + 60 * 1000;
167
167
  if (isNearExpiry && credentials?.refresh_token) {
168
- return refreshSpotifyAccessToken(config, credentials);
168
+ return refreshSpotifyAccessToken(config, credentials, signal);
169
169
  }
170
170
  return credentials;
171
171
  }
172
172
 
173
- async function spotifyRequest(config, credentials, { method = 'GET', path, query, body }) {
174
- let nextCredentials = await ensureSpotifyAccessToken(config, credentials);
175
- const tokenType = String(nextCredentials?.token_type || 'Bearer').trim() || 'Bearer';
173
+ async function spotifyRequest(config, context, { method = 'GET', path, query, body }) {
174
+ const { credentials, signal } = context;
175
+ let nextCredentials = await ensureSpotifyAccessToken(config, credentials, signal);
176
176
 
177
177
  const performRequest = async (tokenCreds) => {
178
178
  const tokenType = String(tokenCreds?.token_type || 'Bearer').trim() || 'Bearer';
179
- const response = await fetch(spotifyUrl(path, query), {
180
- method: String(method || 'GET').toUpperCase(),
181
- headers: {
182
- Authorization: `${tokenType} ${tokenCreds.access_token}`,
183
- Accept: 'application/json',
184
- ...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
179
+ const { response, text } = await fetchResponseText(
180
+ spotifyUrl(path, query),
181
+ {
182
+ method: String(method || 'GET').toUpperCase(),
183
+ headers: {
184
+ Authorization: `${tokenType} ${tokenCreds.access_token}`,
185
+ Accept: 'application/json',
186
+ ...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
187
+ },
188
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
189
+ signal,
185
190
  },
186
- ...(body === undefined ? {} : { body: JSON.stringify(body) }),
187
- });
191
+ { serviceName: 'Spotify' },
192
+ );
188
193
 
189
194
  if (response.status === 204) {
190
195
  return { ok: true, data: null, response };
191
196
  }
192
197
 
193
- const text = await response.text();
194
198
  let parsed = null;
195
199
  try {
196
200
  parsed = text ? JSON.parse(text) : null;
@@ -202,7 +206,7 @@ async function spotifyRequest(config, credentials, { method = 'GET', path, query
202
206
 
203
207
  let result = await performRequest(nextCredentials);
204
208
  if (!result.ok && result.response.status === 401 && nextCredentials.refresh_token) {
205
- nextCredentials = await refreshSpotifyAccessToken(config, nextCredentials);
209
+ nextCredentials = await refreshSpotifyAccessToken(config, nextCredentials, signal);
206
210
  result = await performRequest(nextCredentials);
207
211
  }
208
212
 
@@ -219,18 +223,18 @@ async function spotifyRequest(config, credentials, { method = 'GET', path, query
219
223
  };
220
224
  }
221
225
 
222
- async function executeSpotifyTool(toolName, args, { credentials }) {
226
+ async function executeSpotifyTool(toolName, args, context) {
223
227
  const config = resolveSpotifyOAuthConfig();
224
228
  switch (toolName) {
225
229
  case 'spotify_get_current_playback': {
226
- const { data, credentials: updated } = await spotifyRequest(config, credentials, {
230
+ const { data, credentials: updated } = await spotifyRequest(config, context, {
227
231
  path: '/v1/me/player',
228
232
  });
229
233
  return { result: data || { is_playing: false }, credentials: updated };
230
234
  }
231
235
  case 'spotify_get_recently_played': {
232
236
  const limit = Math.max(1, Math.min(Number(args.limit) || 20, 50));
233
- const { data, credentials: updated } = await spotifyRequest(config, credentials, {
237
+ const { data, credentials: updated } = await spotifyRequest(config, context, {
234
238
  path: '/v1/me/player/recently-played',
235
239
  query: { limit },
236
240
  });
@@ -246,7 +250,7 @@ async function executeSpotifyTool(toolName, args, { credentials }) {
246
250
  const type = types.length > 0 ? types.join(',') : 'track';
247
251
  const limit = Math.max(1, Math.min(Number(args.limit) || 10, 50));
248
252
  const market = String(args.market || '').trim().toUpperCase();
249
- const { data, credentials: updated } = await spotifyRequest(config, credentials, {
253
+ const { data, credentials: updated } = await spotifyRequest(config, context, {
250
254
  path: '/v1/search',
251
255
  query: {
252
256
  q: queryText,
@@ -348,7 +352,7 @@ async function executeSpotifyTool(toolName, args, { credentials }) {
348
352
  throw new Error('Unsupported action. Use play, pause, next, previous, seek, set_volume, shuffle, or repeat.');
349
353
  }
350
354
 
351
- const { data, credentials: updated } = await spotifyRequest(config, credentials, request);
355
+ const { data, credentials: updated } = await spotifyRequest(config, context, request);
352
356
  return {
353
357
  result: {
354
358
  action,
@@ -359,7 +363,7 @@ async function executeSpotifyTool(toolName, args, { credentials }) {
359
363
  };
360
364
  }
361
365
  case 'spotify_api_request': {
362
- const { data, credentials: updated } = await spotifyRequest(config, credentials, {
366
+ const { data, credentials: updated } = await spotifyRequest(config, context, {
363
367
  method: args.method,
364
368
  path: requireText(args.path, 'path'),
365
369
  query: args.query,
@@ -372,20 +376,16 @@ async function executeSpotifyTool(toolName, args, { credentials }) {
372
376
  }
373
377
  }
374
378
 
375
- async function fetchSpotifyProfile(accessToken) {
376
- const response = await fetch('https://api.spotify.com/v1/me', {
377
- method: 'GET',
378
- headers: {
379
- Authorization: `Bearer ${accessToken}`,
380
- Accept: 'application/json',
379
+ async function fetchSpotifyProfile(accessToken, signal = null) {
380
+ return fetchJson(
381
+ 'https://api.spotify.com/v1/me',
382
+ {
383
+ method: 'GET',
384
+ headers: { Authorization: `Bearer ${accessToken}` },
385
+ signal,
381
386
  },
382
- });
383
- const payload = await response.json().catch(() => ({}));
384
- if (!response.ok) {
385
- const message = payload?.error?.message || payload?.error || `${response.status} ${response.statusText}`;
386
- throw new Error(`Spotify profile request failed: ${message}`);
387
- }
388
- return payload;
387
+ { serviceName: 'Spotify profile' },
388
+ );
389
389
  }
390
390
 
391
391
  function createSpotifyProvider() {
@@ -418,27 +418,23 @@ function createSpotifyProvider() {
418
418
  appId: app.id,
419
419
  };
420
420
  },
421
- async finishOAuth({ code, app }) {
421
+ async finishOAuth({ code, app, signal }) {
422
422
  const config = resolveSpotifyOAuthConfig();
423
423
  const basic = Buffer.from(`${config.clientId}:${config.clientSecret}`).toString('base64');
424
- const tokenResponse = await fetch('https://accounts.spotify.com/api/token', {
425
- method: 'POST',
426
- headers: {
427
- Authorization: `Basic ${basic}`,
428
- 'Content-Type': 'application/x-www-form-urlencoded',
429
- Accept: 'application/json',
424
+ const token = await fetchJson(
425
+ 'https://accounts.spotify.com/api/token',
426
+ {
427
+ method: 'POST',
428
+ headers: { Authorization: `Basic ${basic}` },
429
+ form: {
430
+ grant_type: 'authorization_code',
431
+ code,
432
+ redirect_uri: config.redirectUri,
433
+ },
434
+ signal,
430
435
  },
431
- body: new URLSearchParams({
432
- grant_type: 'authorization_code',
433
- code,
434
- redirect_uri: config.redirectUri,
435
- }).toString(),
436
- });
437
- const token = await tokenResponse.json().catch(() => ({}));
438
- if (!tokenResponse.ok) {
439
- const message = token?.error_description || token?.error || `${tokenResponse.status} ${tokenResponse.statusText}`;
440
- throw new Error(`Spotify OAuth token exchange failed: ${message}`);
441
- }
436
+ { serviceName: 'Spotify OAuth token exchange' },
437
+ );
442
438
 
443
439
  const accessToken = String(token?.access_token || '').trim();
444
440
  if (!accessToken) {
@@ -449,7 +445,7 @@ function createSpotifyProvider() {
449
445
  throw new Error('Spotify OAuth did not return a refresh token.');
450
446
  }
451
447
 
452
- const profile = await fetchSpotifyProfile(accessToken);
448
+ const profile = await fetchSpotifyProfile(accessToken, signal);
453
449
  const accountEmail =
454
450
  String(profile?.email || '').trim() ||
455
451
  (String(profile?.id || '').trim() ? `spotify:${String(profile.id).trim()}` : 'spotify_user');
@@ -276,7 +276,10 @@ function trelloUrl(path, query = {}, config = {}) {
276
276
  async function trelloRequest(config, options = {}) {
277
277
  return fetchJson(
278
278
  trelloUrl(options.path, options.query, config),
279
- { method: String(options.method || 'GET').toUpperCase() },
279
+ {
280
+ method: String(options.method || 'GET').toUpperCase(),
281
+ signal: options.signal || config.signal,
282
+ },
280
283
  { serviceName: 'Trello' },
281
284
  );
282
285
  }
@@ -492,8 +495,15 @@ function resolveTrelloCredentials(connection, credentials = {}) {
492
495
  return { apiKey, token };
493
496
  }
494
497
 
495
- async function executeTrelloTool(toolName, args, { connection, credentials }) {
496
- const config = resolveTrelloCredentials(connection, credentials);
498
+ async function executeTrelloTool(
499
+ toolName,
500
+ args,
501
+ { connection, credentials, signal },
502
+ ) {
503
+ const config = {
504
+ ...resolveTrelloCredentials(connection, credentials),
505
+ signal,
506
+ };
497
507
 
498
508
  switch (toolName) {
499
509
  case 'trello_get_me': {
@@ -731,14 +741,18 @@ function createTrelloProvider() {
731
741
  }
732
742
  return 'Trello: native Trello access is connected in this run with one Trello account for board, list, card, comment, and search tools.';
733
743
  },
734
- async executeTool(toolName, args, connection) {
744
+ async executeTool(toolName, args, connection, executionOptions = {}) {
735
745
  let credentials = {};
736
746
  try {
737
747
  credentials = JSON.parse(decryptValue(connection.credentials_json || '{}') || '{}');
738
748
  } catch {
739
749
  credentials = {};
740
750
  }
741
- return executeTrelloTool(toolName, args, { connection, credentials });
751
+ return executeTrelloTool(toolName, args, {
752
+ connection,
753
+ credentials,
754
+ signal: executionOptions.signal || null,
755
+ });
742
756
  },
743
757
  getUserConfig({ userId, agentId }) {
744
758
  const normalizedUserId = Number(userId);
@@ -757,7 +771,7 @@ function createTrelloProvider() {
757
771
  hasConnectedAccount: accountCount > 0,
758
772
  };
759
773
  },
760
- async saveUserConfig({ userId, agentId, config }) {
774
+ async saveUserConfig({ userId, agentId, config, signal }) {
761
775
  const normalizedUserId = Number(userId);
762
776
  if (!Number.isInteger(normalizedUserId) || normalizedUserId <= 0) {
763
777
  throw new Error('A valid user is required to save Trello configuration.');
@@ -796,7 +810,7 @@ function createTrelloProvider() {
796
810
 
797
811
  let profile;
798
812
  try {
799
- profile = await fetchTrelloMemberProfile({ apiKey, token });
813
+ profile = await fetchTrelloMemberProfile({ apiKey, token, signal });
800
814
  } catch (error) {
801
815
  const message = String(error?.message || '').toLowerCase();
802
816
  if (
@@ -152,14 +152,14 @@ function cleanLocationResult(item = {}) {
152
152
  };
153
153
  }
154
154
 
155
- async function geocodeLocation(locationQuery, limit = 1) {
155
+ async function geocodeLocation(locationQuery, limit = 1, signal = null) {
156
156
  const query = String(locationQuery || '').trim();
157
157
  if (!query) {
158
158
  throw new Error('location is required when latitude/longitude are not provided.');
159
159
  }
160
160
  const result = await fetchJson(
161
161
  `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(query)}&count=${Math.max(1, Math.min(Number(limit) || 1, 10))}&language=en&format=json`,
162
- { method: 'GET' },
162
+ { method: 'GET', signal },
163
163
  { serviceName: 'Open-Meteo geocoding' },
164
164
  );
165
165
  const matches = Array.isArray(result?.results) ? result.results : [];
@@ -169,7 +169,7 @@ async function geocodeLocation(locationQuery, limit = 1) {
169
169
  return matches.map(cleanLocationResult);
170
170
  }
171
171
 
172
- async function resolveLocation(args = {}, connection = null) {
172
+ async function resolveLocation(args = {}, connection = null, signal = null) {
173
173
  const latitude = toNumber(args.latitude, null);
174
174
  const longitude = toNumber(args.longitude, null);
175
175
  if (latitude !== null && longitude !== null) {
@@ -183,7 +183,7 @@ async function resolveLocation(args = {}, connection = null) {
183
183
 
184
184
  const location = String(args.location || '').trim();
185
185
  if (location) {
186
- const [best] = await geocodeLocation(location, 1);
186
+ const [best] = await geocodeLocation(location, 1, signal);
187
187
  return {
188
188
  latitude: best.latitude,
189
189
  longitude: best.longitude,
@@ -221,7 +221,7 @@ function enrichCurrent(current = {}) {
221
221
  };
222
222
  }
223
223
 
224
- async function fetchForecastForLocation(location, forecastHours = 24) {
224
+ async function fetchForecastForLocation(location, forecastHours = 24, signal = null) {
225
225
  const horizon = Math.max(1, Math.min(Number(forecastHours) || 24, 72));
226
226
  const url = new URL('https://api.open-meteo.com/v1/forecast');
227
227
  url.searchParams.set('latitude', String(location.latitude));
@@ -259,7 +259,11 @@ async function fetchForecastForLocation(location, forecastHours = 24) {
259
259
  url.searchParams.set('forecast_hours', String(horizon));
260
260
  url.searchParams.set('timezone', location.timezone || 'auto');
261
261
 
262
- const result = await fetchJson(url.toString(), { method: 'GET' }, { serviceName: 'Open-Meteo forecast' });
262
+ const result = await fetchJson(
263
+ url.toString(),
264
+ { method: 'GET', signal },
265
+ { serviceName: 'Open-Meteo forecast' },
266
+ );
263
267
  const hourly = result?.hourly || {};
264
268
  const times = Array.isArray(hourly.time) ? hourly.time : [];
265
269
  const rows = times.map((time, index) => {
@@ -304,6 +308,7 @@ class WeatherProvider {
304
308
  this.sessions = new Map();
305
309
  this.sessionTTL = 30 * 60 * 1000;
306
310
  this.#pruneTimer = setInterval(() => this.pruneExpiredSessions(), this.sessionTTL);
311
+ this.#pruneTimer.unref?.();
307
312
  }
308
313
 
309
314
  #pruneTimer = null;
@@ -504,7 +509,8 @@ class WeatherProvider {
504
509
  }
505
510
  }
506
511
 
507
- async executeTool(toolName, args, connection) {
512
+ async executeTool(toolName, args, connection, executionOptions = {}) {
513
+ const signal = executionOptions.signal || null;
508
514
  switch (toolName) {
509
515
  case 'weather_search_locations': {
510
516
  const query = String(args.query || '').trim();
@@ -512,7 +518,7 @@ class WeatherProvider {
512
518
  throw new Error('query is required.');
513
519
  }
514
520
  const limit = Math.max(1, Math.min(Number(args.limit) || 5, 10));
515
- const results = await geocodeLocation(query, limit);
521
+ const results = await geocodeLocation(query, limit, signal);
516
522
  return {
517
523
  result: {
518
524
  query,
@@ -522,8 +528,8 @@ class WeatherProvider {
522
528
  };
523
529
  }
524
530
  case 'weather_get_current': {
525
- const location = await resolveLocation(args, connection);
526
- const forecast = await fetchForecastForLocation(location, 1);
531
+ const location = await resolveLocation(args, connection, signal);
532
+ const forecast = await fetchForecastForLocation(location, 1, signal);
527
533
  return {
528
534
  result: {
529
535
  location: forecast.location,
@@ -532,9 +538,9 @@ class WeatherProvider {
532
538
  };
533
539
  }
534
540
  case 'weather_get_forecast': {
535
- const location = await resolveLocation(args, connection);
541
+ const location = await resolveLocation(args, connection, signal);
536
542
  const forecastHours = Math.max(1, Math.min(Number(args.forecast_hours) || 24, 72));
537
- const forecast = await fetchForecastForLocation(location, forecastHours);
543
+ const forecast = await fetchForecastForLocation(location, forecastHours, signal);
538
544
  return {
539
545
  result: {
540
546
  location: forecast.location,
@@ -9,6 +9,10 @@ const { AGENT_DATA_DIR } = require('../../../../runtime/paths');
9
9
  const { encryptValue, decryptValue } = require('../secrets');
10
10
  const { withConnectionAccessMode } = require('../access');
11
11
  const { normalizeWhatsAppId, toWhatsAppJid } = require('../../../utils/whatsapp');
12
+ const {
13
+ waitForAbortableResult,
14
+ waitForBoundedResult,
15
+ } = require('../http');
12
16
 
13
17
  const WHATSAPP_APP = {
14
18
  id: 'personal',
@@ -222,6 +226,7 @@ class WhatsAppPersonalProvider extends EventEmitter {
222
226
  this.reconnectTimers = new Map();
223
227
  this.sessionReconnectTimers = new Map();
224
228
  this.io = options.io || null;
229
+ this.shuttingDown = false;
225
230
  }
226
231
 
227
232
  _clearReconnectTimer(connectionId) {
@@ -241,7 +246,7 @@ class WhatsAppPersonalProvider extends EventEmitter {
241
246
  }
242
247
 
243
248
  _scheduleReconnect(client, attempt = 1) {
244
- if (!client?.connectionId || client.manualDisconnect) {
249
+ if (this.shuttingDown || !client?.connectionId || client.manualDisconnect) {
245
250
  return;
246
251
  }
247
252
  this._clearReconnectTimer(client.connectionId);
@@ -267,11 +272,17 @@ class WhatsAppPersonalProvider extends EventEmitter {
267
272
  }
268
273
  }
269
274
  }, delayMs);
275
+ timer.unref?.();
270
276
  this.reconnectTimers.set(client.connectionId, timer);
271
277
  }
272
278
 
273
279
  _scheduleSessionReconnect(session, attempt = 1) {
274
- if (!session?.id || session.status === 'connected' || session.status === 'logged_out') {
280
+ if (
281
+ this.shuttingDown ||
282
+ !session?.id ||
283
+ session.status === 'connected' ||
284
+ session.status === 'logged_out'
285
+ ) {
275
286
  return;
276
287
  }
277
288
  this._clearSessionReconnectTimer(session.id);
@@ -290,6 +301,7 @@ class WhatsAppPersonalProvider extends EventEmitter {
290
301
  current.error = err?.message || 'connection_failed';
291
302
  });
292
303
  }, delayMs);
304
+ timer.unref?.();
293
305
  this.sessionReconnectTimers.set(session.id, timer);
294
306
  }
295
307
 
@@ -451,6 +463,9 @@ class WhatsAppPersonalProvider extends EventEmitter {
451
463
  }
452
464
 
453
465
  async beginConnection({ userId, agentId, appKey }) {
466
+ if (this.shuttingDown) {
467
+ throw new Error('WhatsApp integration is shutting down.');
468
+ }
454
469
  if (!this.getApp(appKey)) {
455
470
  throw new Error(`Unknown ${this.label} app: ${appKey || 'missing app key'}`);
456
471
  }
@@ -533,8 +548,9 @@ class WhatsAppPersonalProvider extends EventEmitter {
533
548
  }
534
549
  }
535
550
 
536
- async executeTool(toolName, args, connection) {
537
- const client = await this._ensureClient(connection);
551
+ async executeTool(toolName, args, connection, executionOptions = {}) {
552
+ const signal = executionOptions.signal || null;
553
+ const client = await this._ensureClient(connection, { signal });
538
554
 
539
555
  switch (toolName) {
540
556
  case 'whatsapp_personal_get_profile':
@@ -584,9 +600,16 @@ class WhatsAppPersonalProvider extends EventEmitter {
584
600
  }
585
601
  case 'whatsapp_personal_send_message': {
586
602
  const jid = this._normalizeChatId(requireText(args.to, 'to'));
587
- await client.socket.sendMessage(jid, {
588
- text: requireText(args.text, 'text'),
589
- });
603
+ await waitForBoundedResult(
604
+ client.socket.sendMessage(jid, {
605
+ text: requireText(args.text, 'text'),
606
+ }),
607
+ {
608
+ signal,
609
+ timeoutMs: 60000,
610
+ serviceName: 'WhatsApp send',
611
+ },
612
+ );
590
613
  return {
591
614
  result: {
592
615
  sent: true,
@@ -900,13 +923,13 @@ class WhatsAppPersonalProvider extends EventEmitter {
900
923
  return row?.id;
901
924
  }
902
925
 
903
- async _ensureClient(connection) {
926
+ async _ensureClient(connection, options = {}) {
904
927
  const existing = this.clients.get(connection.id);
905
928
  if (existing?.status === 'connected' && existing.socket) {
906
929
  return existing;
907
930
  }
908
931
  if (existing?.connectPromise) {
909
- await existing.connectPromise;
932
+ await waitForAbortableResult(existing.connectPromise, options.signal);
910
933
  const ready = this.clients.get(connection.id);
911
934
  if (ready?.status === 'connected' && ready.socket) {
912
935
  return ready;
@@ -935,16 +958,16 @@ class WhatsAppPersonalProvider extends EventEmitter {
935
958
  logger: createSilentLogger(),
936
959
  connectPromise: null,
937
960
  };
938
- client.connectPromise = this._connectClient(client);
961
+ const connectPromise = this._connectClient(client);
962
+ client.connectPromise = connectPromise;
939
963
  this.clients.set(connection.id, client);
940
- try {
941
- await client.connectPromise;
942
- } finally {
964
+ void connectPromise.finally(() => {
943
965
  const current = this.clients.get(connection.id);
944
- if (current) {
966
+ if (current?.connectPromise === connectPromise) {
945
967
  current.connectPromise = null;
946
968
  }
947
- }
969
+ }).catch(() => {});
970
+ await waitForAbortableResult(connectPromise, options.signal);
948
971
  const ready = this.clients.get(connection.id);
949
972
  if (!ready?.socket || ready.status !== 'connected') {
950
973
  throw new Error('WhatsApp personal account is not connected.');
@@ -953,6 +976,9 @@ class WhatsAppPersonalProvider extends EventEmitter {
953
976
  }
954
977
 
955
978
  async _connectClient(client) {
979
+ if (this.shuttingDown) {
980
+ throw new Error('WhatsApp integration is shutting down.');
981
+ }
956
982
  ensureDir(client.authDir);
957
983
  client.manualDisconnect = false;
958
984
  const {
@@ -981,6 +1007,9 @@ class WhatsAppPersonalProvider extends EventEmitter {
981
1007
  if (Array.isArray(version) && version.length > 0) {
982
1008
  socketConfig.version = version;
983
1009
  }
1010
+ try {
1011
+ client.socket?.end(new Error('client_reconnect'));
1012
+ } catch {}
984
1013
  const sock = makeWASocket(socketConfig);
985
1014
 
986
1015
  client.socket = sock;
@@ -989,6 +1018,10 @@ class WhatsAppPersonalProvider extends EventEmitter {
989
1018
 
990
1019
  await new Promise((resolve, reject) => {
991
1020
  const timeout = setTimeout(() => {
1021
+ client.status = 'disconnected';
1022
+ try {
1023
+ sock.end(new Error('connection_timeout'));
1024
+ } catch {}
992
1025
  reject(new Error('WhatsApp connection timed out.'));
993
1026
  }, 60000);
994
1027
 
@@ -1017,7 +1050,10 @@ class WhatsAppPersonalProvider extends EventEmitter {
1017
1050
 
1018
1051
  async _resolveBaileysVersion(fetchLatestBaileysVersion) {
1019
1052
  try {
1020
- const result = await fetchLatestBaileysVersion();
1053
+ const result = await waitForBoundedResult(fetchLatestBaileysVersion(), {
1054
+ timeoutMs: 15000,
1055
+ serviceName: 'WhatsApp version discovery',
1056
+ });
1021
1057
  if (Array.isArray(result?.version) && result.version.length > 0) {
1022
1058
  return result.version;
1023
1059
  }
@@ -1026,6 +1062,30 @@ class WhatsAppPersonalProvider extends EventEmitter {
1026
1062
  }
1027
1063
  return null;
1028
1064
  }
1065
+
1066
+ async shutdown() {
1067
+ this.shuttingDown = true;
1068
+ for (const timer of this.reconnectTimers.values()) clearTimeout(timer);
1069
+ for (const timer of this.sessionReconnectTimers.values()) clearTimeout(timer);
1070
+ this.reconnectTimers.clear();
1071
+ this.sessionReconnectTimers.clear();
1072
+
1073
+ const sockets = new Set();
1074
+ for (const client of this.clients.values()) {
1075
+ client.manualDisconnect = true;
1076
+ if (client.socket) sockets.add(client.socket);
1077
+ }
1078
+ for (const session of this.sessions.values()) {
1079
+ if (session.socket) sockets.add(session.socket);
1080
+ }
1081
+ for (const socket of sockets) {
1082
+ try {
1083
+ socket.end(new Error('server_shutdown'));
1084
+ } catch {}
1085
+ }
1086
+ this.clients.clear();
1087
+ this.sessions.clear();
1088
+ }
1029
1089
  }
1030
1090
 
1031
1091
  function createWhatsAppPersonalProvider(options = {}) {