neoagent 3.2.1-beta.1 → 3.2.1-beta.11
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.
- package/extensions/chrome-browser/background.mjs +318 -88
- package/extensions/chrome-browser/http.mjs +136 -0
- package/extensions/chrome-browser/protocol.mjs +654 -90
- package/flutter_app/lib/main_chat.dart +118 -739
- package/flutter_app/lib/main_controller.dart +157 -24
- package/flutter_app/lib/main_integrations.dart +607 -8
- package/flutter_app/lib/main_models.dart +7 -2
- package/flutter_app/lib/main_operations.dart +334 -370
- package/flutter_app/lib/main_security.dart +266 -112
- package/flutter_app/lib/main_settings.dart +342 -3
- package/flutter_app/lib/main_shared.dart +14 -11
- package/flutter_app/lib/src/backend_client.dart +97 -0
- package/flutter_app/lib/src/desktop_companion_actions.dart +185 -31
- package/flutter_app/lib/src/desktop_companion_io.dart +319 -86
- package/flutter_app/windows/runner/flutter_window.cpp +143 -32
- package/landing/index.html +3 -1
- package/lib/manager.js +106 -89
- package/lib/schema_migrations.js +115 -13
- package/package.json +30 -15
- package/runtime/paths.js +49 -5
- package/server/db/database.js +2 -2
- package/server/guest-agent.cli.package.json +13 -0
- package/server/guest_agent.js +85 -40
- package/server/http/middleware.js +24 -0
- package/server/http/routes.js +12 -6
- package/server/public/.last_build_id +1 -1
- package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
- package/server/public/flutter_bootstrap.js +2 -2
- package/server/public/main.dart.js +81930 -80509
- package/server/routes/admin.js +1 -1
- package/server/routes/android.js +30 -34
- package/server/routes/behavior.js +80 -0
- package/server/routes/browser.js +23 -15
- package/server/routes/desktop.js +18 -1
- package/server/routes/integrations.js +107 -1
- package/server/routes/memory.js +1 -0
- package/server/routes/settings.js +20 -5
- package/server/routes/social_reach.js +12 -3
- package/server/routes/social_video.js +4 -0
- package/server/services/agents/manager.js +1 -1
- package/server/services/ai/capabilityHealth.js +62 -96
- package/server/services/ai/compaction.js +7 -2
- package/server/services/ai/history.js +45 -6
- package/server/services/ai/integrated_tools/http_request.js +8 -0
- package/server/services/ai/loop/agent_engine_core.js +452 -176
- package/server/services/ai/loop/blank_recovery.js +5 -4
- package/server/services/ai/loop/callbacks.js +1 -0
- package/server/services/ai/loop/completion_judge.js +291 -8
- package/server/services/ai/loop/conversation_loop.js +515 -342
- package/server/services/ai/loop/messaging_delivery.js +176 -57
- package/server/services/ai/loop/model_call_guard.js +91 -0
- package/server/services/ai/loop/model_io.js +20 -45
- package/server/services/ai/loop/progress_classification.js +2 -0
- package/server/services/ai/loop/tool_dispatch.js +19 -8
- package/server/services/ai/loopPolicy.js +48 -21
- package/server/services/ai/messagingFallback.js +17 -17
- package/server/services/ai/model_discovery.js +227 -0
- package/server/services/ai/model_failure_cache.js +108 -0
- package/server/services/ai/model_identity.js +71 -0
- package/server/services/ai/models.js +68 -163
- package/server/services/ai/providerRetry.js +17 -59
- package/server/services/ai/provider_selector.js +166 -0
- package/server/services/ai/providers/anthropic.js +2 -2
- package/server/services/ai/providers/claudeCode.js +21 -33
- package/server/services/ai/providers/githubCopilot.js +41 -20
- package/server/services/ai/providers/google.js +135 -97
- package/server/services/ai/providers/grok.js +4 -3
- package/server/services/ai/providers/grokOauth.js +19 -27
- package/server/services/ai/providers/nvidia.js +10 -5
- package/server/services/ai/providers/ollama.js +111 -84
- package/server/services/ai/providers/ollama_stream.js +142 -0
- package/server/services/ai/providers/openai.js +39 -5
- package/server/services/ai/providers/openaiCodex.js +11 -4
- package/server/services/ai/providers/openrouter.js +29 -7
- package/server/services/ai/providers/provider_error.js +36 -0
- package/server/services/ai/settings.js +26 -2
- package/server/services/ai/systemPrompt.js +32 -123
- package/server/services/ai/taskAnalysis.js +104 -11
- package/server/services/ai/toolEvidence.js +256 -29
- package/server/services/ai/tools.js +248 -117
- package/server/services/android/controller.js +770 -237
- package/server/services/android/process.js +140 -0
- package/server/services/android/sdk_download.js +143 -0
- package/server/services/android/uia.js +6 -5
- package/server/services/artifacts/store.js +24 -0
- package/server/services/behavior/config.js +251 -0
- package/server/services/behavior/defaults.js +68 -0
- package/server/services/behavior/delivery.js +176 -0
- package/server/services/behavior/index.js +43 -0
- package/server/services/behavior/model_client.js +35 -0
- package/server/services/behavior/modules/agent_identity.js +37 -0
- package/server/services/behavior/modules/channel_style.js +28 -0
- package/server/services/behavior/modules/index.js +29 -0
- package/server/services/behavior/modules/norms.js +101 -0
- package/server/services/behavior/modules/persona.js +48 -0
- package/server/services/behavior/modules/persona_prompt.js +33 -0
- package/server/services/behavior/modules/social_memory.js +86 -0
- package/server/services/behavior/modules/social_observability.js +94 -0
- package/server/services/behavior/modules/social_signals.js +41 -0
- package/server/services/behavior/modules/theory_of_mind.js +110 -0
- package/server/services/behavior/modules/turn_taking.js +237 -0
- package/server/services/behavior/pipeline.js +285 -0
- package/server/services/behavior/registry.js +78 -0
- package/server/services/behavior/signals.js +107 -0
- package/server/services/behavior/state.js +99 -0
- package/server/services/behavior/system_prompt.js +75 -0
- package/server/services/browser/controller.js +843 -385
- package/server/services/browser/extension/gateway.js +40 -16
- package/server/services/browser/extension/protocol.js +15 -1
- package/server/services/browser/extension/provider.js +71 -47
- package/server/services/browser/extension/registry.js +155 -34
- package/server/services/cli/executor.js +62 -9
- package/server/services/credentials/bitwarden_cli.js +322 -0
- package/server/services/credentials/broker.js +594 -0
- package/server/services/desktop/gateway.js +41 -4
- package/server/services/desktop/protocol.js +3 -0
- package/server/services/desktop/provider.js +39 -42
- package/server/services/desktop/registry.js +137 -52
- package/server/services/integrations/bitwarden/constants.js +14 -0
- package/server/services/integrations/bitwarden/provider.js +197 -0
- package/server/services/integrations/bitwarden/snapshot.js +65 -0
- package/server/services/integrations/figma/provider.js +78 -12
- package/server/services/integrations/github/common.js +11 -6
- package/server/services/integrations/github/provider.js +52 -53
- package/server/services/integrations/google/provider.js +55 -19
- package/server/services/integrations/home_assistant/network.js +17 -20
- package/server/services/integrations/home_assistant/provider.js +7 -5
- package/server/services/integrations/home_assistant/tools.js +17 -5
- package/server/services/integrations/http.js +51 -0
- package/server/services/integrations/manager.js +159 -53
- package/server/services/integrations/microsoft/provider.js +80 -13
- package/server/services/integrations/neoarchive/provider.js +55 -29
- package/server/services/integrations/neorecall/client.js +17 -10
- package/server/services/integrations/neorecall/provider.js +20 -11
- package/server/services/integrations/notion/provider.js +16 -13
- package/server/services/integrations/oauth_provider.js +115 -51
- package/server/services/integrations/registry.js +2 -0
- package/server/services/integrations/slack/provider.js +98 -9
- package/server/services/integrations/spotify/provider.js +67 -71
- package/server/services/integrations/trello/provider.js +21 -7
- package/server/services/integrations/weather/provider.js +18 -12
- package/server/services/integrations/whatsapp/provider.js +76 -16
- package/server/services/manager.js +110 -1
- package/server/services/memory/embedding_index.js +20 -8
- package/server/services/memory/embeddings.js +151 -90
- package/server/services/memory/ingestion.js +50 -9
- package/server/services/memory/ingestion_documents.js +13 -3
- package/server/services/memory/manager.js +66 -52
- package/server/services/messaging/access_policy.js +10 -6
- package/server/services/messaging/automation.js +240 -34
- package/server/services/messaging/discord.js +11 -1
- package/server/services/messaging/formatting_guides.js +5 -4
- package/server/services/messaging/http_platforms.js +37 -16
- package/server/services/messaging/inbound_queue.js +143 -28
- package/server/services/messaging/inbound_store.js +257 -0
- package/server/services/messaging/manager.js +344 -51
- package/server/services/messaging/telegram.js +10 -1
- package/server/services/messaging/typing_keepalive.js +5 -2
- package/server/services/messaging/whatsapp.js +33 -14
- package/server/services/network/http.js +210 -0
- package/server/services/network/safe_request.js +307 -0
- package/server/services/runtime/backends/local-vm.js +227 -67
- package/server/services/runtime/docker-vm-manager.js +9 -0
- package/server/services/runtime/guest_bootstrap.js +30 -4
- package/server/services/runtime/guest_image.js +43 -12
- package/server/services/runtime/manager.js +77 -23
- package/server/services/runtime/validation.js +7 -6
- package/server/services/security/tool_categories.js +6 -0
- package/server/services/social_reach/channels/github.js +10 -4
- package/server/services/social_reach/channels/reddit.js +4 -4
- package/server/services/social_reach/channels/rss.js +2 -2
- package/server/services/social_reach/channels/social_video.js +13 -8
- package/server/services/social_reach/channels/v2ex.js +21 -8
- package/server/services/social_reach/channels/x.js +2 -2
- package/server/services/social_reach/channels/xueqiu.js +5 -5
- package/server/services/social_reach/service.js +9 -6
- package/server/services/social_reach/utils.js +65 -14
- package/server/services/social_video/captions.js +2 -2
- package/server/services/social_video/service.js +343 -68
- package/server/services/tasks/integration_runtime.js +18 -8
- package/server/services/tasks/runtime.js +39 -4
- package/server/services/voice/agentBridge.js +17 -4
- package/server/services/voice/bufferedLiveRelayAdapter.js +5 -0
- package/server/services/voice/liveSession.js +31 -0
- package/server/services/voice/message.js +1 -1
- package/server/services/voice/openaiSpeech.js +33 -8
- package/server/services/voice/providers.js +233 -151
- package/server/services/voice/runtime.js +2 -2
- package/server/services/voice/runtimeManager.js +118 -20
- package/server/services/voice/turnRunner.js +6 -0
- package/server/services/wearable/firmware_manifest.js +51 -13
- package/server/services/wearable/service.js +1 -0
- package/server/utils/abort.js +96 -0
- package/server/utils/cloud-security.js +110 -3
- package/server/utils/files.js +31 -0
- package/server/utils/image_payload.js +95 -0
- package/server/utils/logger.js +19 -0
- 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
|
-
|
|
78
|
-
|
|
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
|
-
|
|
166
|
-
|
|
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(
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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
|
|
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 =
|
|
144
|
+
error.body = text.slice(0, 2000);
|
|
133
145
|
throw error;
|
|
134
146
|
}
|
|
135
|
-
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
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
|
+
};
|