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
@@ -16,6 +16,15 @@ const {
16
16
  sanitizeSpeechText,
17
17
  } = require('./providers');
18
18
  const { VoiceAgentBridge } = require('./agentBridge');
19
+ const { waitForBoundedResult } = require('../network/http');
20
+ const { createAbortError, throwIfAborted } = require('../../utils/abort');
21
+
22
+ function voiceRuntimeStoppedError() {
23
+ const error = new Error('Voice runtime is shutting down.');
24
+ error.name = 'AbortError';
25
+ error.code = 'VOICE_RUNTIME_SHUTDOWN';
26
+ return error;
27
+ }
19
28
 
20
29
  class VoiceRuntimeManager {
21
30
  constructor({ io, agentEngine, memoryManager }) {
@@ -23,6 +32,8 @@ class VoiceRuntimeManager {
23
32
  this.agentEngine = agentEngine;
24
33
  this.memoryManager = memoryManager;
25
34
  this.sessions = new Map();
35
+ this.shuttingDown = false;
36
+ this.shutdownPromise = null;
26
37
  this.agentBridge = new VoiceAgentBridge({
27
38
  agentEngine,
28
39
  memoryManager,
@@ -34,6 +45,10 @@ class VoiceRuntimeManager {
34
45
  return this.sessions.get(String(sessionId || '').trim()) || null;
35
46
  }
36
47
 
48
+ #assertRunning() {
49
+ if (this.shuttingDown) throw voiceRuntimeStoppedError();
50
+ }
51
+
37
52
  async openSession({
38
53
  userId,
39
54
  agentId = null,
@@ -42,6 +57,7 @@ class VoiceRuntimeManager {
42
57
  sink,
43
58
  outputMode = 'audio_and_text',
44
59
  } = {}) {
60
+ this.#assertRunning();
45
61
  if (!sink) {
46
62
  throw new Error('A voice session sink is required.');
47
63
  }
@@ -57,6 +73,14 @@ class VoiceRuntimeManager {
57
73
  }
58
74
  const adapter = this.#createAdapter(voiceSettings.liveProvider);
59
75
  await adapter.open();
76
+ if (this.shuttingDown) {
77
+ await adapter.close?.(resolvedSessionId).catch(() => {});
78
+ throw voiceRuntimeStoppedError();
79
+ }
80
+ if (this.sessions.has(resolvedSessionId)) {
81
+ await adapter.close?.(resolvedSessionId).catch(() => {});
82
+ throw new Error('Voice session ID collision: a session with this ID already exists.');
83
+ }
60
84
 
61
85
  const session = new VoiceLiveSession({
62
86
  id: resolvedSessionId,
@@ -74,8 +98,15 @@ class VoiceRuntimeManager {
74
98
 
75
99
  session.adapter = adapter;
76
100
  this.sessions.set(resolvedSessionId, session);
77
- await session.publishReady();
78
- return session;
101
+ try {
102
+ await session.publishReady();
103
+ return session;
104
+ } catch (error) {
105
+ this.sessions.delete(resolvedSessionId);
106
+ await session.close('startup_failed').catch(() => {});
107
+ await adapter.close?.(resolvedSessionId).catch(() => {});
108
+ throw error;
109
+ }
79
110
  }
80
111
 
81
112
  async openFlutterSession({ userId, agentId = null, socket, sessionId = null } = {}) {
@@ -162,12 +193,12 @@ class VoiceRuntimeManager {
162
193
  if (userId != null && session.userId != null && String(session.userId) !== String(userId)) {
163
194
  throw new Error('Voice session access denied.');
164
195
  }
165
- if (reason === 'socket_disconnected') {
166
- await this.abortActiveRun(session.id, 'voice_disconnect');
167
- }
196
+ await this.abortActiveRun(session.id, reason === 'socket_disconnected'
197
+ ? 'voice_disconnect'
198
+ : 'voice_session_closed');
168
199
  this.sessions.delete(session.id);
169
- await session.adapter?.close?.(session.id);
170
200
  await session.close(reason);
201
+ await session.adapter?.close?.(session.id);
171
202
  }
172
203
 
173
204
  async beginInput(sessionId, options = {}, userId = null) {
@@ -255,7 +286,13 @@ class VoiceRuntimeManager {
255
286
  };
256
287
  }
257
288
 
258
- async deliverDeferredVoiceFollowUp(session, followUpPlan, replyText, runId = null) {
289
+ async deliverDeferredVoiceFollowUp(
290
+ session,
291
+ followUpPlan,
292
+ replyText,
293
+ runId = null,
294
+ options = {},
295
+ ) {
259
296
  if (!session || session.closed || !followUpPlan || session.deferFollowUpRequested !== true) {
260
297
  return { sent: false, skipped: true };
261
298
  }
@@ -298,9 +335,11 @@ class VoiceRuntimeManager {
298
335
  agentId: session.agentId,
299
336
  runId,
300
337
  persistConversation: true,
338
+ signal: options.signal,
301
339
  },
302
340
  );
303
341
  } catch (err) {
342
+ if (options.signal?.aborted) throw createAbortError(options.signal);
304
343
  console.error('Failed to send deferred voice follow-up:', err);
305
344
  return {
306
345
  sent: false,
@@ -334,6 +373,7 @@ class VoiceRuntimeManager {
334
373
  sink,
335
374
  metadata = null,
336
375
  } = {}) {
376
+ this.#assertRunning();
337
377
  const sessionId = `telnyx:${userId}:${callId}`;
338
378
  let session = this.getSession(sessionId);
339
379
  if (!session) {
@@ -358,10 +398,18 @@ class VoiceRuntimeManager {
358
398
  });
359
399
  session.adapter = this.#createAdapter(voiceSettings.liveProvider);
360
400
  await session.adapter.open();
401
+ if (this.shuttingDown) {
402
+ await session.close('server_shutdown').catch(() => {});
403
+ await session.adapter.close?.(sessionId).catch(() => {});
404
+ throw voiceRuntimeStoppedError();
405
+ }
361
406
  this.sessions.set(sessionId, session);
362
407
  }
363
408
 
364
409
  await session.interruptOutput();
410
+ await this.abortActiveRun(session.id, 'voice_interrupt');
411
+ session.resetTurnState();
412
+ const turnSignal = session.signal;
365
413
  await session.setState('thinking');
366
414
 
367
415
  try {
@@ -370,7 +418,9 @@ class VoiceRuntimeManager {
370
418
  });
371
419
  return result;
372
420
  } finally {
373
- await session.setState('idle');
421
+ if (!session.closed && session.signal === turnSignal && !turnSignal.aborted) {
422
+ await session.setState('idle');
423
+ }
374
424
  }
375
425
  }
376
426
 
@@ -378,7 +428,29 @@ class VoiceRuntimeManager {
378
428
  const session = this.getSession(sessionId);
379
429
  const runId = session?.currentRunId;
380
430
  if (!runId || !this.agentEngine) return;
381
- this.agentEngine.abort(runId, reason);
431
+ this.agentEngine.abort(runId, { userId: session.userId, reason });
432
+ }
433
+
434
+ shutdown() {
435
+ if (this.shutdownPromise) return this.shutdownPromise;
436
+ this.shuttingDown = true;
437
+ const sessionIds = Array.from(this.sessions.keys());
438
+ const closing = Promise.allSettled(
439
+ sessionIds.map((sessionId) => this.closeSession(sessionId, 'server_shutdown')),
440
+ );
441
+ this.shutdownPromise = waitForBoundedResult(closing, {
442
+ serviceName: 'Voice runtime shutdown',
443
+ timeoutMs: 10000,
444
+ }).then((results) => ({
445
+ state: 'stopped',
446
+ timedOut: false,
447
+ failedSessionCount: results.filter((result) => result.status === 'rejected').length,
448
+ })).catch((error) => ({
449
+ state: 'timeout',
450
+ timedOut: error?.code === 'HTTP_TIMEOUT',
451
+ error: error?.message || String(error),
452
+ }));
453
+ return this.shutdownPromise;
382
454
  }
383
455
 
384
456
  #createAdapter(provider) {
@@ -389,6 +461,7 @@ class VoiceRuntimeManager {
389
461
  }
390
462
 
391
463
  #requireSession(sessionId, userId = null) {
464
+ this.#assertRunning();
392
465
  const session = this.getSession(sessionId);
393
466
  if (!session) {
394
467
  throw new Error('Voice session was not found.');
@@ -466,6 +539,8 @@ class VoiceRuntimeManager {
466
539
  }
467
540
 
468
541
  async #deliverFlutterAssistantOutput(socket, sessionId, session, content, options = {}) {
542
+ const turnSignal = session.signal;
543
+ throwIfAborted(turnSignal, 'Voice output was interrupted.');
469
544
  const kind = String(options.kind || 'final').trim() || 'final';
470
545
  socket.emit('voice:assistant_text', {
471
546
  sessionId,
@@ -486,7 +561,7 @@ class VoiceRuntimeManager {
486
561
 
487
562
  let index = 0;
488
563
  let streamError = null;
489
- const ttsAttempts = this.#buildTtsAttemptOrder(session, voiceOptions);
564
+ const ttsAttempts = this.#buildTtsAttemptOrder(session, voiceOptions, turnSignal);
490
565
  if (spokenContent) {
491
566
  try {
492
567
  for (const attempt of ttsAttempts) {
@@ -498,7 +573,12 @@ class VoiceRuntimeManager {
498
573
  spokenContent,
499
574
  attempt,
500
575
  async ({ audioBytes, mimeType }) => {
501
- if (session.closed || session.interrupted) return;
576
+ if (
577
+ session.closed
578
+ || session.interrupted
579
+ || turnSignal.aborted
580
+ || session.signal !== turnSignal
581
+ ) return;
502
582
  socket.emit('voice:audio_chunk', {
503
583
  sessionId,
504
584
  kind,
@@ -516,6 +596,7 @@ class VoiceRuntimeManager {
516
596
  streamError = null;
517
597
  break;
518
598
  } catch (error) {
599
+ if (turnSignal.aborted || session.signal !== turnSignal) return;
519
600
  streamError = String(error?.message || error || 'Voice playback failed.');
520
601
  console.warn(`[VoiceRuntime] ${attempt.provider} TTS failed for flutter session ${sessionId}: ${streamError}`);
521
602
  if (attemptChunks > 0) {
@@ -524,13 +605,16 @@ class VoiceRuntimeManager {
524
605
  }
525
606
  }
526
607
  } catch (error) {
608
+ if (turnSignal.aborted || session.signal !== turnSignal) return;
527
609
  streamError = String(error?.message || error || 'Voice playback failed.');
528
610
  }
529
611
  }
530
612
 
531
- if (!streamError && !session.closed && !session.interrupted) {
613
+ if (!streamError && !session.closed && !session.interrupted
614
+ && !turnSignal.aborted && session.signal === turnSignal) {
532
615
  socket.emit('voice:audio_done', { sessionId, kind, totalChunks: index });
533
- } else if (kind === 'final' && !session.closed && !session.interrupted) {
616
+ } else if (kind === 'final' && !session.closed && !session.interrupted
617
+ && !turnSignal.aborted && session.signal === turnSignal) {
534
618
  socket.emit('voice:error', {
535
619
  sessionId,
536
620
  error: streamError,
@@ -540,7 +624,8 @@ class VoiceRuntimeManager {
540
624
  await session.setState('degraded', { kind, phase: 'tts' });
541
625
  }
542
626
 
543
- if (kind === 'final' && !streamError) {
627
+ if (kind === 'final' && !streamError
628
+ && !turnSignal.aborted && session.signal === turnSignal) {
544
629
  await session.setState('idle');
545
630
  }
546
631
  }
@@ -550,6 +635,8 @@ class VoiceRuntimeManager {
550
635
  if (!session) {
551
636
  return;
552
637
  }
638
+ const turnSignal = session.signal;
639
+ throwIfAborted(turnSignal, 'Voice output was interrupted.');
553
640
  const kind = String(options.kind || 'final').trim() || 'final';
554
641
  ws.send(JSON.stringify({
555
642
  type: 'voice:assistant_text',
@@ -572,7 +659,7 @@ class VoiceRuntimeManager {
572
659
  const spokenContent = sanitizeSpeechText(content);
573
660
  let index = 0;
574
661
  let streamError = null;
575
- const ttsAttempts = this.#buildTtsAttemptOrder(session, voiceOptions);
662
+ const ttsAttempts = this.#buildTtsAttemptOrder(session, voiceOptions, turnSignal);
576
663
 
577
664
  if (spokenContent) {
578
665
  try {
@@ -585,7 +672,12 @@ class VoiceRuntimeManager {
585
672
  spokenContent,
586
673
  attempt,
587
674
  async ({ audioBytes, mimeType }) => {
588
- if (session.closed || session.interrupted) return;
675
+ if (
676
+ session.closed
677
+ || session.interrupted
678
+ || turnSignal.aborted
679
+ || session.signal !== turnSignal
680
+ ) return;
589
681
  ws.send(JSON.stringify({
590
682
  type: 'voice:audio_chunk',
591
683
  sessionId,
@@ -603,6 +695,7 @@ class VoiceRuntimeManager {
603
695
  }
604
696
  break;
605
697
  } catch (error) {
698
+ if (turnSignal.aborted || session.signal !== turnSignal) return;
606
699
  streamError = String(error?.message || error || 'Voice playback failed.');
607
700
  console.warn(`[VoiceRuntime] ${attempt.provider} TTS failed for wearable session ${sessionId}: ${streamError}`);
608
701
  if (attemptChunks > 0) {
@@ -611,18 +704,21 @@ class VoiceRuntimeManager {
611
704
  }
612
705
  }
613
706
  } catch (error) {
707
+ if (turnSignal.aborted || session.signal !== turnSignal) return;
614
708
  streamError = String(error?.message || error || 'Voice playback failed.');
615
709
  }
616
710
  }
617
711
 
618
- if (!streamError && !session.closed && !session.interrupted) {
712
+ if (!streamError && !session.closed && !session.interrupted
713
+ && !turnSignal.aborted && session.signal === turnSignal) {
619
714
  ws.send(JSON.stringify({
620
715
  type: 'voice:audio_done',
621
716
  sessionId,
622
717
  kind,
623
718
  totalChunks: index,
624
719
  }));
625
- } else if (kind === 'final' && !session.closed && !session.interrupted) {
720
+ } else if (kind === 'final' && !session.closed && !session.interrupted
721
+ && !turnSignal.aborted && session.signal === turnSignal) {
626
722
  ws.send(JSON.stringify({
627
723
  type: 'voice:error',
628
724
  sessionId,
@@ -633,12 +729,13 @@ class VoiceRuntimeManager {
633
729
  await session.setState('degraded', { kind, phase: 'tts' });
634
730
  }
635
731
 
636
- if (kind === 'final' && !streamError) {
732
+ if (kind === 'final' && !streamError
733
+ && !turnSignal.aborted && session.signal === turnSignal) {
637
734
  await session.setState('idle');
638
735
  }
639
736
  }
640
737
 
641
- #buildTtsAttemptOrder(session, voiceOptions) {
738
+ #buildTtsAttemptOrder(session, voiceOptions, signal = null) {
642
739
  const attempts = [];
643
740
  const providers = [
644
741
  voiceOptions.provider,
@@ -663,6 +760,7 @@ class VoiceRuntimeManager {
663
760
  apiKey: runtime.apiKey,
664
761
  baseUrl: runtime.baseUrl,
665
762
  timeoutMs: 20000,
763
+ signal,
666
764
  });
667
765
  }
668
766
  return attempts;
@@ -13,6 +13,7 @@ const {
13
13
  VOICE_HISTORY_WINDOW,
14
14
  buildDirectVoiceRunOptions,
15
15
  } = require('./runtime');
16
+ const { createAbortError, throwIfAborted } = require('../../utils/abort');
16
17
 
17
18
  async function runVoiceTranscriptTurn({
18
19
  userId,
@@ -30,10 +31,12 @@ async function runVoiceTranscriptTurn({
30
31
  allowInterimUpdates = false,
31
32
  voiceSessionId = null,
32
33
  runId = null,
34
+ signal = null,
33
35
  }) {
34
36
  if (!agentEngine || !memoryManager) {
35
37
  throw new Error('Voice turn service is not initialized.');
36
38
  }
39
+ throwIfAborted(signal, 'Voice turn aborted before startup.');
37
40
 
38
41
  const transcriptText = String(transcript || '').trim();
39
42
  if (!transcriptText) {
@@ -83,6 +86,7 @@ async function runVoiceTranscriptTurn({
83
86
  priorMessages,
84
87
  priorSummary,
85
88
  voiceSessionId,
89
+ signal,
86
90
  context: {
87
91
  rawUserMessage: storedUserContent,
88
92
  additionalContext: directVoiceContext,
@@ -161,6 +165,7 @@ async function runVoiceTranscriptTurn({
161
165
  apiKey: runtime.apiKey,
162
166
  baseUrl: runtime.baseUrl,
163
167
  timeoutMs: 12000,
168
+ signal,
164
169
  });
165
170
  providerUsed = normalized.provider;
166
171
  modelUsed = normalized.model;
@@ -168,6 +173,7 @@ async function runVoiceTranscriptTurn({
168
173
  ttsError = null;
169
174
  break;
170
175
  } catch (error) {
176
+ if (signal?.aborted) throw createAbortError(signal);
171
177
  lastTtsError = error;
172
178
  }
173
179
  }
@@ -1,8 +1,14 @@
1
1
  'use strict';
2
2
 
3
+ const { createAbortError } = require('../../utils/abort');
4
+ const { fetchResponseText } = require('../network/http');
5
+
3
6
  const DEFAULT_GITHUB_REPOSITORY = 'NeoLabs-Systems/NeoAgent';
4
7
  const DEFAULT_ASSET_NAME = 'neoagent-wearable-firmware.bin';
5
8
  const MANIFEST_CACHE_TTL_MS = 5 * 60 * 1000;
9
+ const FIRMWARE_HTTP_TIMEOUT_MS = 15000;
10
+ const RELEASES_MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
11
+ const CHECKSUM_MAX_RESPONSE_BYTES = 64 * 1024;
6
12
  const manifestCache = new Map();
7
13
 
8
14
  function trimString(value, maxLength = 512) {
@@ -118,36 +124,53 @@ function selectChecksumAsset(release, assetName) {
118
124
  }) || null;
119
125
  }
120
126
 
121
- async function fetchGithubJson(fetchImpl, url, token) {
122
- const response = await fetchImpl(url, {
127
+ async function fetchGithubJson(fetchImpl, url, token, signal) {
128
+ const { response, text } = await fetchResponseText(url, {
129
+ fetchImpl,
123
130
  headers: {
124
131
  Accept: 'application/vnd.github+json',
125
132
  ...(token ? { Authorization: `Bearer ${token}` } : {}),
126
133
  },
134
+ signal,
135
+ timeoutMs: FIRMWARE_HTTP_TIMEOUT_MS,
136
+ maxResponseBytes: RELEASES_MAX_RESPONSE_BYTES,
137
+ serviceName: 'GitHub firmware release lookup',
138
+ timeoutCode: 'FIRMWARE_HTTP_TIMEOUT',
139
+ tooLargeCode: 'FIRMWARE_RESPONSE_TOO_LARGE',
127
140
  });
128
141
  if (!response.ok) {
129
- const body = await response.text().catch(() => '');
130
142
  const error = new Error(`GitHub API request failed with ${response.status}`);
131
143
  error.status = response.status;
132
- error.body = body;
144
+ error.body = text.slice(0, 2000);
133
145
  throw error;
134
146
  }
135
- return response.json();
147
+ try {
148
+ return JSON.parse(text);
149
+ } catch (cause) {
150
+ throw new Error('GitHub firmware release lookup returned malformed JSON.', { cause });
151
+ }
136
152
  }
137
153
 
138
- async function fetchText(fetchImpl, url, token) {
139
- const response = await fetchImpl(url, {
154
+ async function fetchText(fetchImpl, url, token, signal) {
155
+ const { response, text } = await fetchResponseText(url, {
156
+ fetchImpl,
140
157
  headers: {
141
158
  Accept: 'text/plain, application/octet-stream;q=0.9, */*;q=0.1',
142
159
  ...(token ? { Authorization: `Bearer ${token}` } : {}),
143
160
  },
161
+ signal,
162
+ timeoutMs: FIRMWARE_HTTP_TIMEOUT_MS,
163
+ maxResponseBytes: CHECKSUM_MAX_RESPONSE_BYTES,
164
+ serviceName: 'Firmware checksum download',
165
+ timeoutCode: 'FIRMWARE_HTTP_TIMEOUT',
166
+ tooLargeCode: 'FIRMWARE_RESPONSE_TOO_LARGE',
144
167
  });
145
168
  if (!response.ok) {
146
169
  const error = new Error(`Asset request failed with ${response.status}`);
147
170
  error.status = response.status;
148
171
  throw error;
149
172
  }
150
- return response.text();
173
+ return text;
151
174
  }
152
175
 
153
176
  function parseChecksumBody(body, assetName) {
@@ -168,9 +191,14 @@ function parseChecksumBody(body, assetName) {
168
191
  return null;
169
192
  }
170
193
 
171
- async function fetchGithubRelease(fetchImpl, repository, channel, token, assetName = null) {
194
+ async function fetchGithubRelease(fetchImpl, repository, channel, token, assetName = null, signal = null) {
172
195
  const normalizedChannel = normalizeChannel(channel);
173
- const releases = await fetchGithubJson(fetchImpl, `https://api.github.com/repos/${repository}/releases?per_page=100`, token);
196
+ const releases = await fetchGithubJson(
197
+ fetchImpl,
198
+ `https://api.github.com/repos/${repository}/releases?per_page=100`,
199
+ token,
200
+ signal,
201
+ );
174
202
  const release = selectGithubRelease(releases, normalizedChannel, assetName);
175
203
  if (!release) {
176
204
  const error = new Error(`No ${normalizedChannel} firmware release found for ${repository}`);
@@ -212,6 +240,7 @@ async function resolveFirmwareManifest({
212
240
  repositoryOverride,
213
241
  assetNameOverride,
214
242
  fetchImpl = fetch,
243
+ signal = null,
215
244
  } = {}) {
216
245
  const normalizedChannel = normalizeChannel(channel);
217
246
  const repository = parseRepositorySlug(repositoryOverride) || getGithubRepository();
@@ -264,7 +293,14 @@ async function resolveFirmwareManifest({
264
293
 
265
294
  try {
266
295
  const token = getGithubToken();
267
- const release = await fetchGithubRelease(fetchImpl, repository, normalizedChannel, token, assetName);
296
+ const release = await fetchGithubRelease(
297
+ fetchImpl,
298
+ repository,
299
+ normalizedChannel,
300
+ token,
301
+ assetName,
302
+ signal,
303
+ );
268
304
  const asset = selectReleaseAsset(release, assetName);
269
305
  if (!asset || !asset.browser_download_url) {
270
306
  return {
@@ -293,10 +329,11 @@ async function resolveFirmwareManifest({
293
329
  if (checksumAsset?.browser_download_url) {
294
330
  try {
295
331
  checksum = parseChecksumBody(
296
- await fetchText(fetchImpl, checksumAsset.browser_download_url, token),
332
+ await fetchText(fetchImpl, checksumAsset.browser_download_url, token, signal),
297
333
  asset.name,
298
334
  );
299
- } catch {
335
+ } catch (error) {
336
+ if (signal?.aborted) throw createAbortError(signal);
300
337
  checksum = null;
301
338
  }
302
339
  }
@@ -322,6 +359,7 @@ async function resolveFirmwareManifest({
322
359
  setCachedManifest(cacheId, manifest);
323
360
  return manifest;
324
361
  } catch (error) {
362
+ if (signal?.aborted) throw createAbortError(signal);
325
363
  return {
326
364
  configured: false,
327
365
  source: 'github',
@@ -136,6 +136,7 @@ class WearableService {
136
136
  channel,
137
137
  repositoryOverride: toTrimmedString(process.env.NEOAGENT_WEARABLE_FIRMWARE_GITHUB_REPOSITORY, 256),
138
138
  assetNameOverride: toTrimmedString(process.env.NEOAGENT_WEARABLE_FIRMWARE_ASSET_NAME, 128),
139
+ signal: req?.signal,
139
140
  });
140
141
  return {
141
142
  ...manifest,
@@ -0,0 +1,96 @@
1
+ 'use strict';
2
+
3
+ function createAbortError(signalOrReason = null, fallback = 'Operation aborted.') {
4
+ const reason = signalOrReason && typeof signalOrReason === 'object'
5
+ && 'aborted' in signalOrReason
6
+ ? signalOrReason.reason
7
+ : signalOrReason;
8
+ if (reason instanceof Error) return reason;
9
+ const error = new Error(
10
+ String(reason || fallback),
11
+ );
12
+ error.name = 'AbortError';
13
+ error.code = 'ABORT_ERR';
14
+ return error;
15
+ }
16
+
17
+ function isAbortError(error, signal = null) {
18
+ return signal?.aborted === true
19
+ || error?.name === 'AbortError'
20
+ || error?.code === 'ABORT_ERR';
21
+ }
22
+
23
+ function throwIfAborted(signal, fallback = 'Operation aborted.') {
24
+ if (signal?.aborted) throw createAbortError(signal, fallback);
25
+ }
26
+
27
+ function createLinkedAbortController(signals = []) {
28
+ const controller = new AbortController();
29
+ const listeners = new Map();
30
+ for (const signal of new Set(signals.filter(Boolean))) {
31
+ if (typeof signal.addEventListener !== 'function') continue;
32
+ const forwardAbort = () => {
33
+ if (!controller.signal.aborted) controller.abort(signal.reason);
34
+ };
35
+ listeners.set(signal, forwardAbort);
36
+ if (signal.aborted) {
37
+ forwardAbort();
38
+ break;
39
+ }
40
+ signal.addEventListener('abort', forwardAbort, { once: true });
41
+ }
42
+ return {
43
+ controller,
44
+ signal: controller.signal,
45
+ cleanup() {
46
+ for (const [signal, forwardAbort] of listeners) {
47
+ signal.removeEventListener('abort', forwardAbort);
48
+ }
49
+ listeners.clear();
50
+ },
51
+ };
52
+ }
53
+
54
+ async function runWithAbortTimeout(factory, options = {}) {
55
+ const timeoutMs = Number(options.timeoutMs);
56
+ const timeoutController = new AbortController();
57
+ const linked = createLinkedAbortController([
58
+ options.signal,
59
+ timeoutController.signal,
60
+ ]);
61
+ throwIfAborted(linked.signal, `${options.label || 'Operation'} aborted.`);
62
+ let timer = null;
63
+ let abortListener = null;
64
+ if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
65
+ timer = setTimeout(() => {
66
+ const error = new Error(
67
+ `${options.label || 'Operation'} timed out after ${Math.floor(timeoutMs)}ms.`,
68
+ );
69
+ error.code = options.timeoutCode || 'OPERATION_TIMEOUT';
70
+ timeoutController.abort(error);
71
+ }, timeoutMs);
72
+ }
73
+ const aborted = new Promise((_, reject) => {
74
+ abortListener = () => reject(createAbortError(linked.signal));
75
+ linked.signal.addEventListener('abort', abortListener, { once: true });
76
+ });
77
+
78
+ try {
79
+ return await Promise.race([
80
+ Promise.resolve().then(() => factory(linked.signal)),
81
+ aborted,
82
+ ]);
83
+ } finally {
84
+ if (timer) clearTimeout(timer);
85
+ if (abortListener) linked.signal.removeEventListener('abort', abortListener);
86
+ linked.cleanup();
87
+ }
88
+ }
89
+
90
+ module.exports = {
91
+ createAbortError,
92
+ createLinkedAbortController,
93
+ isAbortError,
94
+ runWithAbortTimeout,
95
+ throwIfAborted,
96
+ };