neoagent 3.0.1-beta.2 → 3.0.1-beta.21
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/.env.example +19 -0
- package/README.md +20 -3
- package/docs/benchmarking.md +102 -0
- package/docs/billing.md +224 -0
- package/docs/configuration.md +22 -0
- package/docs/getting-started.md +10 -8
- package/docs/index.md +1 -0
- package/docs/operations.md +1 -1
- package/flutter_app/lib/main.dart +4 -1
- package/flutter_app/lib/main_account_settings.dart +138 -0
- package/flutter_app/lib/main_app_shell.dart +316 -0
- package/flutter_app/lib/main_billing.dart +1465 -0
- package/flutter_app/lib/main_chat.dart +1594 -224
- package/flutter_app/lib/main_controller.dart +263 -26
- package/flutter_app/lib/main_devices.dart +1 -1
- package/flutter_app/lib/main_install.dart +1147 -0
- package/flutter_app/lib/main_navigation.dart +12 -0
- package/flutter_app/lib/main_operations.dart +134 -9
- package/flutter_app/lib/main_settings.dart +148 -52
- package/flutter_app/lib/main_shared.dart +1 -0
- package/flutter_app/lib/src/backend_client.dart +86 -1
- package/landing/index.html +2 -2
- package/lib/manager.js +184 -66
- package/lib/schema_migrations.js +84 -0
- package/package.json +10 -4
- package/server/admin/access.js +12 -7
- package/server/admin/admin.css +78 -0
- package/server/admin/admin.js +511 -23
- package/server/admin/billing.js +415 -0
- package/server/admin/index.html +120 -13
- package/server/admin/users.js +15 -15
- package/server/db/database.js +13 -21
- package/server/db/sessions_db.js +8 -0
- package/server/http/middleware.js +4 -4
- package/server/http/routes.js +9 -0
- package/server/http/static.js +4 -2
- package/server/middleware/requireBilling.js +12 -0
- package/server/public/.last_build_id +1 -1
- package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
- package/server/public/flutter_bootstrap.js +1 -1
- package/server/public/main.dart.js +89347 -85449
- package/server/routes/account.js +53 -0
- package/server/routes/admin.js +515 -63
- package/server/routes/agents.js +203 -21
- package/server/routes/billing.js +127 -0
- package/server/routes/billing_webhook.js +43 -0
- package/server/routes/memory.js +1 -4
- package/server/routes/settings.js +1 -2
- package/server/routes/skills.js +1 -1
- package/server/routes/triggers.js +2 -2
- package/server/services/account/erasure.js +263 -0
- package/server/services/account/sessions.js +1 -9
- package/server/services/ai/loop/agent_engine_core.js +42 -0
- package/server/services/ai/loop/blank_recovery.js +36 -0
- package/server/services/ai/loop/completion_judge.js +42 -0
- package/server/services/ai/loop/conversation_loop.js +248 -34
- package/server/services/ai/loop/progress_monitor.js +6 -6
- package/server/services/ai/loopPolicy.js +37 -44
- package/server/services/ai/messagingFallback.js +25 -1
- package/server/services/ai/models.js +18 -0
- package/server/services/ai/preModelCompaction.js +25 -5
- package/server/services/ai/rate_limits.js +28 -4
- package/server/services/ai/systemPrompt.js +23 -5
- package/server/services/ai/taskAnalysis.js +2 -0
- package/server/services/ai/tools.js +231 -20
- package/server/services/android/controller.js +6 -2
- package/server/services/billing/billing_email.js +106 -0
- package/server/services/billing/config.js +17 -0
- package/server/services/billing/plans.js +121 -0
- package/server/services/billing/stripe_client.js +21 -0
- package/server/services/billing/subscriptions.js +462 -0
- package/server/services/billing/trial_guard.js +111 -0
- package/server/services/browser/contentExtractor.js +629 -0
- package/server/services/browser/controller.js +4 -8
- package/server/services/desktop/gateway.js +7 -4
- package/server/services/integrations/google/calendar.js +30 -2
- package/server/services/integrations/secrets.js +7 -4
- package/server/services/manager.js +11 -0
- package/server/services/messaging/automation.js +1 -1
- package/server/services/messaging/telnyx.js +12 -11
- package/server/services/messaging/whatsapp.js +1 -1
- package/server/services/recordings/manager.js +13 -7
- package/server/services/runtime/backends/local-vm.js +40 -22
- package/server/services/runtime/docker-vm-manager.js +157 -266
- package/server/services/runtime/guest_bootstrap.js +17 -5
- package/server/services/runtime/guest_image.js +182 -0
- package/server/services/runtime/manager.js +0 -1
- package/server/services/runtime/validation.js +3 -8
- package/server/services/tasks/runtime.js +103 -15
- package/server/services/tasks/schedule_utils.js +5 -5
- package/server/services/voice/runtimeManager.js +19 -10
- package/server/services/wearable/gateway.js +8 -5
- package/server/services/websocket.js +26 -8
- package/server/services/workspace/manager.js +37 -3
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const crypto = require('crypto');
|
|
6
|
+
const { spawnSync } = require('child_process');
|
|
7
|
+
const { DATA_DIR } = require('../../../runtime/paths');
|
|
8
|
+
const { stageGuestPayload, normalizeRuntimeProfile } = require('./guest_bootstrap');
|
|
9
|
+
|
|
10
|
+
// Where build contexts are staged. One directory per profile.
|
|
11
|
+
const BUILD_ROOT = path.join(DATA_DIR, 'runtime-vms', 'guest-image');
|
|
12
|
+
// Slim official Node base — Playwright browsers + OS deps are added at build time
|
|
13
|
+
// via `playwright install --with-deps`, so the image is self-contained and the
|
|
14
|
+
// browser version always matches the pinned `playwright-chromium` dependency.
|
|
15
|
+
const BASE_IMAGE = String(process.env.NEOAGENT_GUEST_BASE_IMAGE || 'node:20-bookworm-slim').trim();
|
|
16
|
+
const IMAGE_REPO = 'neoagent-guest-agent';
|
|
17
|
+
const BUILD_TIMEOUT_MS = Number(process.env.NEOAGENT_GUEST_IMAGE_BUILD_TIMEOUT_MS || 20 * 60 * 1000);
|
|
18
|
+
|
|
19
|
+
// The build context is the staged guest payload (package.json + runtime/ + server/).
|
|
20
|
+
// Dependencies and browsers are installed once, at image build time — never per
|
|
21
|
+
// container start. Copy package.json first so the dependency layer stays cached
|
|
22
|
+
// across source-only changes.
|
|
23
|
+
function dockerfileFor(profile) {
|
|
24
|
+
if (normalizeRuntimeProfile(profile) === 'android') {
|
|
25
|
+
return [
|
|
26
|
+
`FROM ${BASE_IMAGE}`,
|
|
27
|
+
'ENV NODE_ENV=production',
|
|
28
|
+
'ENV NEOAGENT_GUEST_PROFILE=android',
|
|
29
|
+
'WORKDIR /opt/neoagent',
|
|
30
|
+
'COPY package.json ./',
|
|
31
|
+
'RUN npm install --omit=dev --no-audit --no-fund && npm cache clean --force',
|
|
32
|
+
'COPY runtime ./runtime',
|
|
33
|
+
'COPY server ./server',
|
|
34
|
+
// World-writable runtime dir so the agent works whether the container runs
|
|
35
|
+
// as root (macOS) or as the host uid:gid (Linux, for shared file ownership).
|
|
36
|
+
'RUN mkdir -p /opt/neoagent/.runtime && chmod -R 0777 /opt/neoagent/.runtime',
|
|
37
|
+
'CMD ["node", "server/guest_agent.js"]',
|
|
38
|
+
'',
|
|
39
|
+
].join('\n');
|
|
40
|
+
}
|
|
41
|
+
return [
|
|
42
|
+
`FROM ${BASE_IMAGE}`,
|
|
43
|
+
'ENV NODE_ENV=production',
|
|
44
|
+
'ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright',
|
|
45
|
+
'ENV NEOAGENT_GUEST_PROFILE=browser_cli',
|
|
46
|
+
'WORKDIR /opt/neoagent',
|
|
47
|
+
'COPY package.json ./',
|
|
48
|
+
// Install deps without running package postinstall scripts, then fetch the
|
|
49
|
+
// matching Chromium and its OS dependencies explicitly. This mirrors the
|
|
50
|
+
// proven QEMU guest bootstrap and keeps the browser revision deterministic.
|
|
51
|
+
'RUN npm install --omit=dev --ignore-scripts --no-audit --no-fund \\',
|
|
52
|
+
' && npx playwright install --with-deps chromium \\',
|
|
53
|
+
' && npm cache clean --force',
|
|
54
|
+
'COPY runtime ./runtime',
|
|
55
|
+
'COPY server ./server',
|
|
56
|
+
// World-writable runtime dir + browser cache so the agent works whether the
|
|
57
|
+
// container runs as root (macOS) or as the host uid:gid (Linux, for shared
|
|
58
|
+
// file ownership). Chromium needs PLAYWRIGHT_BROWSERS_PATH readable by all.
|
|
59
|
+
'RUN mkdir -p /opt/neoagent/.runtime && chmod -R 0777 /opt/neoagent/.runtime && chmod -R a+rX /ms-playwright',
|
|
60
|
+
'CMD ["node", "server/guest_agent.js"]',
|
|
61
|
+
'',
|
|
62
|
+
].join('\n');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Stable content hash over every staged file plus the Dockerfile, so the image
|
|
66
|
+
// tag changes whenever the guest source, its dependencies, or the base image
|
|
67
|
+
// change. A new tag forces a rebuild; an unchanged tag reuses the cached image.
|
|
68
|
+
function hashBuildContext(contextDir, dockerfile) {
|
|
69
|
+
const hash = crypto.createHash('sha256');
|
|
70
|
+
hash.update(dockerfile);
|
|
71
|
+
const walk = (dir) => {
|
|
72
|
+
for (const name of fs.readdirSync(dir).sort()) {
|
|
73
|
+
const full = path.join(dir, name);
|
|
74
|
+
const stat = fs.statSync(full);
|
|
75
|
+
if (stat.isDirectory()) {
|
|
76
|
+
walk(full);
|
|
77
|
+
} else {
|
|
78
|
+
hash.update(path.relative(contextDir, full));
|
|
79
|
+
hash.update(fs.readFileSync(full));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
walk(contextDir);
|
|
84
|
+
return hash.digest('hex').slice(0, 12);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function docker(args, opts = {}) {
|
|
88
|
+
return spawnSync('docker', args, {
|
|
89
|
+
encoding: 'utf8',
|
|
90
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
91
|
+
timeout: opts.timeout || 30000,
|
|
92
|
+
...opts,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function dockerAvailable() {
|
|
97
|
+
const result = docker(['info'], { timeout: 5000 });
|
|
98
|
+
return !result.error && result.status === 0;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Builds and caches the per-profile guest-agent Docker image. The image bakes in
|
|
102
|
+
// the guest agent, its dependencies, and (for browser_cli) the Chromium browser,
|
|
103
|
+
// so containers start the agent directly with no runtime installation step.
|
|
104
|
+
class GuestImageBuilder {
|
|
105
|
+
#buildPromise = null;
|
|
106
|
+
#cachedTag = null;
|
|
107
|
+
#cachedBuiltAt = 0;
|
|
108
|
+
|
|
109
|
+
constructor(options = {}) {
|
|
110
|
+
this.profile = normalizeRuntimeProfile(options.runtimeProfile || 'browser_cli');
|
|
111
|
+
this.contextDir = path.join(BUILD_ROOT, this.profile);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Stage the build context on disk and return its content-addressed image tag.
|
|
115
|
+
prepareContext() {
|
|
116
|
+
const dockerfile = dockerfileFor(this.profile);
|
|
117
|
+
stageGuestPayload(this.contextDir, this.profile);
|
|
118
|
+
fs.writeFileSync(path.join(this.contextDir, 'Dockerfile'), dockerfile);
|
|
119
|
+
fs.writeFileSync(path.join(this.contextDir, '.dockerignore'), 'node_modules\n');
|
|
120
|
+
const tag = `${IMAGE_REPO}:${this.profile}-${hashBuildContext(this.contextDir, dockerfile)}`;
|
|
121
|
+
this.#cachedTag = tag;
|
|
122
|
+
return tag;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
imageExists(tag) {
|
|
126
|
+
const result = docker(['image', 'inspect', tag], { timeout: 10000 });
|
|
127
|
+
return !result.error && result.status === 0;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Returns { dockerAvailable, imageBuilt, image } without triggering a build.
|
|
131
|
+
// Cheap enough to call from readiness polling (only stages the context once).
|
|
132
|
+
getState() {
|
|
133
|
+
if (!dockerAvailable()) {
|
|
134
|
+
return { dockerAvailable: false, imageBuilt: false, image: this.#cachedTag };
|
|
135
|
+
}
|
|
136
|
+
let tag = this.#cachedTag;
|
|
137
|
+
try {
|
|
138
|
+
if (!tag) tag = this.prepareContext();
|
|
139
|
+
} catch (err) {
|
|
140
|
+
console.warn(`[GuestImage:${this.profile}] Failed to stage build context: ${err.message}`);
|
|
141
|
+
return { dockerAvailable: true, imageBuilt: false, image: null };
|
|
142
|
+
}
|
|
143
|
+
return { dockerAvailable: true, imageBuilt: this.imageExists(tag), image: tag };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Ensure the image exists, building it if necessary. Concurrent callers share
|
|
147
|
+
// the same in-flight build. Returns the resolved image tag.
|
|
148
|
+
async ensure() {
|
|
149
|
+
const tag = this.prepareContext();
|
|
150
|
+
if (this.imageExists(tag)) {
|
|
151
|
+
this.#cachedBuiltAt = Date.now();
|
|
152
|
+
return tag;
|
|
153
|
+
}
|
|
154
|
+
if (this.#buildPromise) return this.#buildPromise;
|
|
155
|
+
this.#buildPromise = this.#build(tag).finally(() => { this.#buildPromise = null; });
|
|
156
|
+
return this.#buildPromise;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async #build(tag) {
|
|
160
|
+
console.log(`[GuestImage:${this.profile}] Building guest image ${tag} (one-time; downloads browser + deps)…`);
|
|
161
|
+
const started = Date.now();
|
|
162
|
+
const result = docker(['build', '-t', tag, this.contextDir], {
|
|
163
|
+
timeout: BUILD_TIMEOUT_MS,
|
|
164
|
+
stdio: ['ignore', 'inherit', 'inherit'],
|
|
165
|
+
});
|
|
166
|
+
if (result.error) {
|
|
167
|
+
throw new Error(`Guest image build failed to start: ${result.error.message}`);
|
|
168
|
+
}
|
|
169
|
+
if (result.status !== 0) {
|
|
170
|
+
throw new Error(`Guest image build for ${tag} exited with status ${result.status}`);
|
|
171
|
+
}
|
|
172
|
+
this.#cachedBuiltAt = Date.now();
|
|
173
|
+
console.log(`[GuestImage:${this.profile}] Built ${tag} in ${Math.round((Date.now() - started) / 1000)}s`);
|
|
174
|
+
return tag;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
module.exports = {
|
|
179
|
+
GuestImageBuilder,
|
|
180
|
+
dockerAvailable,
|
|
181
|
+
BASE_IMAGE,
|
|
182
|
+
};
|
|
@@ -11,14 +11,9 @@ function getRuntimeValidation(runtimeManager) {
|
|
|
11
11
|
|
|
12
12
|
if (policy.profile === 'prod' || nodeEnvIsProd) {
|
|
13
13
|
if (!browserVmReadiness) {
|
|
14
|
-
issues.push('prod profile requires a working
|
|
15
|
-
} else if (!browserVmReadiness.
|
|
16
|
-
|
|
17
|
-
issues.push(`prod profile requires QEMU (${browserVmReadiness.qemuBinary}) to be installed for browser/CLI.`);
|
|
18
|
-
}
|
|
19
|
-
if (!browserVmReadiness.baseImageExists && !browserVmReadiness.downloadConfigured) {
|
|
20
|
-
issues.push('prod profile requires a VM base image or a downloadable base image URL for browser/CLI.');
|
|
21
|
-
}
|
|
14
|
+
issues.push('prod profile requires a working container runtime for browser/CLI.');
|
|
15
|
+
} else if (!browserVmReadiness.dockerAvailable) {
|
|
16
|
+
issues.push('prod profile requires Docker to be installed and running for the browser/CLI runtime.');
|
|
22
17
|
}
|
|
23
18
|
}
|
|
24
19
|
|
|
@@ -14,6 +14,7 @@ const { TriggerRegistry } = require('./trigger_registry');
|
|
|
14
14
|
const scheduleAdapter = require('./adapters/schedule');
|
|
15
15
|
const { normalizeJsonObject } = require('./utils');
|
|
16
16
|
const { normalizeOutgoingMessageForPlatform } = require('../messaging/formatting_guides');
|
|
17
|
+
const { isTransientError } = require('../ai/providerRetry');
|
|
17
18
|
|
|
18
19
|
const MAX_AUTONOMOUS_RETRIES = 1;
|
|
19
20
|
const MAX_RECURRING_TASK_START_DELAY_MS = 90 * 1000;
|
|
@@ -461,6 +462,8 @@ class TaskRuntime {
|
|
|
461
462
|
const deliveryState = {
|
|
462
463
|
messagingSent: false,
|
|
463
464
|
noResponse: false,
|
|
465
|
+
proactiveMessageStaged: false,
|
|
466
|
+
stagedProactiveMessage: null,
|
|
464
467
|
lastSentMessage: '',
|
|
465
468
|
sentMessages: [],
|
|
466
469
|
};
|
|
@@ -483,11 +486,12 @@ class TaskRuntime {
|
|
|
483
486
|
normalizedConfig = this._ensureDefaultNotifyTarget(userId, agentId, taskConfig, taskId);
|
|
484
487
|
const triggerSummary = this._summarizeTrigger(task.trigger_type, triggerConfig);
|
|
485
488
|
let notifyHint = '';
|
|
489
|
+
const manualRun = executionMeta.manual === true;
|
|
486
490
|
|
|
487
491
|
if (normalizedConfig.callTo) {
|
|
488
492
|
notifyHint = `\n\nThis task is configured to notify the user by phone. Use the make_call tool to call "${normalizedConfig.callTo}" with an appropriate greeting based on your findings. The configured greeting hint is: "${normalizedConfig.callGreeting || 'Hello, this is your task reminder.'}"`;
|
|
489
493
|
} else if (normalizedConfig.notifyPlatform && normalizedConfig.notifyTo) {
|
|
490
|
-
notifyHint = `\n\nIf your task result is worth notifying the user about, send it proactively via send_message to platform="${normalizedConfig.notifyPlatform}" to="${normalizedConfig.notifyTo}" and set purpose="final_result" for a concrete useful outcome or purpose="blocker" for a real issue the user should know about. If nothing important or actionable changed, call send_message with purpose="no_response" and content="[NO RESPONSE]"
|
|
494
|
+
notifyHint = `\n\nIf your task result is worth notifying the user about, send it proactively via send_message to platform="${normalizedConfig.notifyPlatform}" to="${normalizedConfig.notifyTo}" and set purpose="final_result" for a concrete useful outcome or purpose="blocker" for a real issue the user should know about. If nothing important or actionable changed, call send_message with purpose="no_response" and content="[NO RESPONSE]" exactly; never leave content blank for no_response. When a tool result already gives you summary fields or flags that answer the task, decide from that evidence instead of re-running nearby variants of the same lookup.${manualRun ? '' : ' For this automatic scheduled run, plain assistant text is internal only and is NOT delivered. You MUST end the run with exactly one explicit send_message decision (purpose="final_result", "blocker", or "no_response") — if you produce a real result, deliver it with send_message or it is lost.'}`;
|
|
491
495
|
}
|
|
492
496
|
|
|
493
497
|
const triggerPayloadText = executionMeta.triggerPayload
|
|
@@ -519,13 +523,14 @@ class TaskRuntime {
|
|
|
519
523
|
taskId,
|
|
520
524
|
deliveryState,
|
|
521
525
|
allowMultipleProactiveMessages: normalizedConfig.allowMultipleMessages === true || normalizedConfig.allow_multiple_messages === true,
|
|
526
|
+
stageProactiveMessages: true,
|
|
522
527
|
skipTaskAnalysis: true,
|
|
523
528
|
skipDeliverableWorkflow: true,
|
|
524
529
|
skipGlobalRecall: true,
|
|
525
530
|
skipConversationHistory: true,
|
|
526
531
|
skipConversationMaintenance: true,
|
|
527
532
|
skipRunContextPersistence: true,
|
|
528
|
-
skipVerifier:
|
|
533
|
+
skipVerifier: false,
|
|
529
534
|
stream: false,
|
|
530
535
|
context: executionMeta.triggerPayload || {},
|
|
531
536
|
};
|
|
@@ -541,6 +546,7 @@ class TaskRuntime {
|
|
|
541
546
|
taskConfig: normalizedConfig,
|
|
542
547
|
result,
|
|
543
548
|
deliveryState,
|
|
549
|
+
allowPlainResultFallback: manualRun,
|
|
544
550
|
});
|
|
545
551
|
if (fallbackDelivery && result && typeof result === 'object') {
|
|
546
552
|
result.taskDelivery = fallbackDelivery;
|
|
@@ -562,10 +568,19 @@ class TaskRuntime {
|
|
|
562
568
|
this.io.to(`user:${userId}`).emit('tasks:task_complete', { taskId, result });
|
|
563
569
|
return result;
|
|
564
570
|
} catch (err) {
|
|
565
|
-
|
|
571
|
+
const transientExecutionError = isTransientError(err);
|
|
572
|
+
if (completedRunId && !transientExecutionError) {
|
|
566
573
|
this.taskRepository.markAgentRunFailed(completedRunId, userId, err.message);
|
|
567
574
|
}
|
|
568
575
|
if (err?.code === 'TASK_DELIVERY_FAILED') throw err;
|
|
576
|
+
if (transientExecutionError) {
|
|
577
|
+
this.io.to(`user:${userId}`).emit('tasks:task_skipped', {
|
|
578
|
+
taskId,
|
|
579
|
+
reason: 'transient_rate_limit',
|
|
580
|
+
timestamp: new Date().toISOString(),
|
|
581
|
+
});
|
|
582
|
+
return { skipped: true, reason: 'transient_rate_limit', runId: completedRunId, error: err.message };
|
|
583
|
+
}
|
|
569
584
|
if (attempt >= MAX_AUTONOMOUS_RETRIES) throw err;
|
|
570
585
|
attempt += 1;
|
|
571
586
|
completedRunId = null;
|
|
@@ -584,20 +599,28 @@ class TaskRuntime {
|
|
|
584
599
|
} catch (err) {
|
|
585
600
|
console.error(`[Tasks] Task ${taskId} error:`, err.message);
|
|
586
601
|
if (err?.code !== 'TASK_DELIVERY_FAILED') {
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
602
|
+
const failureMessage = this._buildTaskFailureMessage(taskName, err);
|
|
603
|
+
// A null message means the failure is transient infrastructure (rate/quota
|
|
604
|
+
// limit) that is not user-actionable and would spam every run during the
|
|
605
|
+
// limit window — it is logged above, but not surfaced to the user.
|
|
606
|
+
if (failureMessage) {
|
|
607
|
+
await this._deliverTaskResultIfNeeded({
|
|
608
|
+
userId,
|
|
609
|
+
agentId,
|
|
610
|
+
taskId,
|
|
611
|
+
taskConfig: normalizedConfig,
|
|
612
|
+
result: {
|
|
613
|
+
content: failureMessage,
|
|
614
|
+
},
|
|
615
|
+
deliveryState,
|
|
616
|
+
allowPlainResultFallback: true,
|
|
617
|
+
});
|
|
618
|
+
}
|
|
597
619
|
}
|
|
598
620
|
this.io.to(`user:${userId}`).emit('tasks:task_skipped', {
|
|
599
621
|
taskId,
|
|
600
622
|
reason: 'execution_failed',
|
|
623
|
+
error: err.message,
|
|
601
624
|
timestamp: new Date().toISOString(),
|
|
602
625
|
});
|
|
603
626
|
return { skipped: false, error: err.message, runId: completedRunId };
|
|
@@ -784,6 +807,29 @@ class TaskRuntime {
|
|
|
784
807
|
return normalized;
|
|
785
808
|
}
|
|
786
809
|
|
|
810
|
+
// Build the user-facing notice for a task that errored out after retries.
|
|
811
|
+
// Returns null when the failure should NOT be surfaced (transient infra limits).
|
|
812
|
+
_buildTaskFailureMessage(taskName, err) {
|
|
813
|
+
const raw = String(err?.message || '').trim();
|
|
814
|
+
const lower = raw.toLowerCase();
|
|
815
|
+
// Rate/quota limits are transient, self-healing, and not user-actionable. Every
|
|
816
|
+
// scheduled run during the window would hit the same error, so a per-run notice
|
|
817
|
+
// would just be spam. Log only (done by the caller), no user message.
|
|
818
|
+
if (
|
|
819
|
+
lower.includes('rate limit')
|
|
820
|
+
|| lower.includes('rate_limit')
|
|
821
|
+
|| lower.includes('quota')
|
|
822
|
+
|| lower.includes('tokens in the last')
|
|
823
|
+
|| /\b429\b/.test(lower)
|
|
824
|
+
) {
|
|
825
|
+
return null;
|
|
826
|
+
}
|
|
827
|
+
// Genuine failure: tell the user the actual reason instead of "check the logs",
|
|
828
|
+
// which they cannot do. Collapse whitespace and cap length to keep it readable.
|
|
829
|
+
const reason = raw ? raw.replace(/\s+/g, ' ').slice(0, 200) : 'an unknown error';
|
|
830
|
+
return `Background task "${taskName}" could not complete: ${reason}`;
|
|
831
|
+
}
|
|
832
|
+
|
|
787
833
|
async _deliverTaskResultIfNeeded({
|
|
788
834
|
userId,
|
|
789
835
|
agentId,
|
|
@@ -791,10 +837,42 @@ class TaskRuntime {
|
|
|
791
837
|
taskConfig,
|
|
792
838
|
result,
|
|
793
839
|
deliveryState,
|
|
840
|
+
allowPlainResultFallback = true,
|
|
794
841
|
}) {
|
|
795
842
|
if (deliveryState?.messagingSent || deliveryState?.noResponse || taskConfig.callTo) return null;
|
|
796
843
|
const targets = this._buildNotifyTargets(userId, agentId, taskConfig);
|
|
797
844
|
if (!targets.length) return null;
|
|
845
|
+
const resultText = stringifyTaskResult(result).trim();
|
|
846
|
+
const resultLooksLikeError = Boolean(result?.error);
|
|
847
|
+
const stagedMessage = normalizeOutgoingMessageForPlatform(
|
|
848
|
+
deliveryState?.stagedProactiveMessage?.platform,
|
|
849
|
+
deliveryState?.stagedProactiveMessage?.content || '',
|
|
850
|
+
{ stripNoResponseMarker: false },
|
|
851
|
+
);
|
|
852
|
+
const explicitStagedDelivery = deliveryState?.proactiveMessageStaged === true && Boolean(stagedMessage);
|
|
853
|
+
// A forced terminal wrap-up (read-only/blocked hard-stop) is the model's final
|
|
854
|
+
// answer produced WITHOUT the ability to call send_message itself. Gating it
|
|
855
|
+
// would silently drop a stuck scheduled task's result, so deliver it even on an
|
|
856
|
+
// automatic run. Ordinary mid-run plain text (model had send_message available
|
|
857
|
+
// and chose not to use it) is still gated below.
|
|
858
|
+
const forcedTerminal = deliveryState?.terminalWrapup === true && Boolean(resultText);
|
|
859
|
+
if (!allowPlainResultFallback && !resultLooksLikeError && !forcedTerminal && !explicitStagedDelivery) {
|
|
860
|
+
// Automatic run produced substantive text but never made an explicit
|
|
861
|
+
// send_message decision (a deliberate no_response would have short-circuited
|
|
862
|
+
// above). We suppress to avoid recurring-check spam, but surface it so a
|
|
863
|
+
// genuinely dropped notification is visible rather than silently lost.
|
|
864
|
+
if (resultText) {
|
|
865
|
+
console.warn(
|
|
866
|
+
`[Tasks] Task ${taskId} produced an undelivered result on an automatic run `
|
|
867
|
+
+ `(no explicit send_message decision): ${resultText.slice(0, 140)}`
|
|
868
|
+
);
|
|
869
|
+
}
|
|
870
|
+
return {
|
|
871
|
+
sent: false,
|
|
872
|
+
skipped: true,
|
|
873
|
+
reason: 'explicit_delivery_required',
|
|
874
|
+
};
|
|
875
|
+
}
|
|
798
876
|
|
|
799
877
|
const manager = this.app?.locals?.messagingManager || this.agentEngine?.messagingManager || null;
|
|
800
878
|
if (!manager) {
|
|
@@ -805,10 +883,17 @@ class TaskRuntime {
|
|
|
805
883
|
}
|
|
806
884
|
|
|
807
885
|
let lastError = null;
|
|
808
|
-
|
|
886
|
+
const resolvedTargets = explicitStagedDelivery
|
|
887
|
+
? [{
|
|
888
|
+
platform: deliveryState.stagedProactiveMessage.platform,
|
|
889
|
+
to: deliveryState.stagedProactiveMessage.to,
|
|
890
|
+
mediaPath: deliveryState.stagedProactiveMessage.mediaPath || null,
|
|
891
|
+
}]
|
|
892
|
+
: targets;
|
|
893
|
+
for (const target of resolvedTargets) {
|
|
809
894
|
const message = normalizeOutgoingMessageForPlatform(
|
|
810
895
|
target.platform,
|
|
811
|
-
|
|
896
|
+
resultText || stagedMessage,
|
|
812
897
|
{ stripNoResponseMarker: false },
|
|
813
898
|
);
|
|
814
899
|
if (!message || message.toUpperCase() === '[NO RESPONSE]') return null;
|
|
@@ -824,10 +909,13 @@ class TaskRuntime {
|
|
|
824
909
|
try {
|
|
825
910
|
const sendResult = await manager.sendMessage(userId, target.platform, target.to, message, {
|
|
826
911
|
agentId,
|
|
912
|
+
mediaPath: target.mediaPath || null,
|
|
827
913
|
runId: result?.runId || null,
|
|
828
914
|
persistConversation: true,
|
|
829
915
|
});
|
|
830
916
|
deliveryState.messagingSent = true;
|
|
917
|
+
deliveryState.proactiveMessageStaged = false;
|
|
918
|
+
deliveryState.stagedProactiveMessage = null;
|
|
831
919
|
deliveryState.lastSentMessage = message;
|
|
832
920
|
if (!Array.isArray(deliveryState.sentMessages)) {
|
|
833
921
|
deliveryState.sentMessages = [];
|
|
@@ -141,11 +141,11 @@ function parseCronExpression(expression) {
|
|
|
141
141
|
}
|
|
142
142
|
|
|
143
143
|
function matchesCron(date, schedule) {
|
|
144
|
-
const minute = date.
|
|
145
|
-
const hour = date.
|
|
146
|
-
const dayOfMonth = date.
|
|
147
|
-
const month = date.
|
|
148
|
-
const dayOfWeek = date.
|
|
144
|
+
const minute = date.getMinutes();
|
|
145
|
+
const hour = date.getHours();
|
|
146
|
+
const dayOfMonth = date.getDate();
|
|
147
|
+
const month = date.getMonth() + 1;
|
|
148
|
+
const dayOfWeek = date.getDay();
|
|
149
149
|
|
|
150
150
|
if (!schedule.minute.values.has(minute)) return false;
|
|
151
151
|
if (!schedule.hour.values.has(hour)) return false;
|
|
@@ -52,6 +52,9 @@ class VoiceRuntimeManager {
|
|
|
52
52
|
agentId,
|
|
53
53
|
);
|
|
54
54
|
const resolvedSessionId = String(sessionId || randomUUID()).trim();
|
|
55
|
+
if (this.sessions.has(resolvedSessionId)) {
|
|
56
|
+
throw new Error('Voice session ID collision: a session with this ID already exists.');
|
|
57
|
+
}
|
|
55
58
|
const adapter = this.#createAdapter(voiceSettings.liveProvider);
|
|
56
59
|
await adapter.open();
|
|
57
60
|
|
|
@@ -153,9 +156,12 @@ class VoiceRuntimeManager {
|
|
|
153
156
|
});
|
|
154
157
|
}
|
|
155
158
|
|
|
156
|
-
async closeSession(sessionId, reason = 'closed') {
|
|
159
|
+
async closeSession(sessionId, reason = 'closed', userId = null) {
|
|
157
160
|
const session = this.getSession(sessionId);
|
|
158
161
|
if (!session) return;
|
|
162
|
+
if (userId != null && session.userId != null && String(session.userId) !== String(userId)) {
|
|
163
|
+
throw new Error('Voice session access denied.');
|
|
164
|
+
}
|
|
159
165
|
if (reason === 'socket_disconnected') {
|
|
160
166
|
await this.abortActiveRun(session.id, 'voice_disconnect');
|
|
161
167
|
}
|
|
@@ -164,8 +170,8 @@ class VoiceRuntimeManager {
|
|
|
164
170
|
await session.close(reason);
|
|
165
171
|
}
|
|
166
172
|
|
|
167
|
-
async beginInput(sessionId, options = {}) {
|
|
168
|
-
const session = this.#requireSession(sessionId);
|
|
173
|
+
async beginInput(sessionId, options = {}, userId = null) {
|
|
174
|
+
const session = this.#requireSession(sessionId, userId);
|
|
169
175
|
await session.interruptOutput();
|
|
170
176
|
await this.abortActiveRun(session.id, 'voice_interrupt');
|
|
171
177
|
session.resetTurnState();
|
|
@@ -176,13 +182,13 @@ class VoiceRuntimeManager {
|
|
|
176
182
|
await session.setState('listening');
|
|
177
183
|
}
|
|
178
184
|
|
|
179
|
-
async appendInputAudio(sessionId, audioBytes, options = {}) {
|
|
180
|
-
const session = this.#requireSession(sessionId);
|
|
185
|
+
async appendInputAudio(sessionId, audioBytes, options = {}, userId = null) {
|
|
186
|
+
const session = this.#requireSession(sessionId, userId);
|
|
181
187
|
return session.adapter.appendAudioChunk(session, audioBytes, options);
|
|
182
188
|
}
|
|
183
189
|
|
|
184
|
-
async commitInput(sessionId, options = {}) {
|
|
185
|
-
const session = this.#requireSession(sessionId);
|
|
190
|
+
async commitInput(sessionId, options = {}, userId = null) {
|
|
191
|
+
const session = this.#requireSession(sessionId, userId);
|
|
186
192
|
if (session.inputBytes === 0) {
|
|
187
193
|
return { transcript: '' };
|
|
188
194
|
}
|
|
@@ -207,8 +213,8 @@ class VoiceRuntimeManager {
|
|
|
207
213
|
};
|
|
208
214
|
}
|
|
209
215
|
|
|
210
|
-
async interruptSession(sessionId) {
|
|
211
|
-
const session = this.#requireSession(sessionId);
|
|
216
|
+
async interruptSession(sessionId, userId = null) {
|
|
217
|
+
const session = this.#requireSession(sessionId, userId);
|
|
212
218
|
await this.abortActiveRun(session.id, 'voice_interrupt');
|
|
213
219
|
await session.interruptOutput();
|
|
214
220
|
session.resetTurnState();
|
|
@@ -378,11 +384,14 @@ class VoiceRuntimeManager {
|
|
|
378
384
|
return new OpenAiLiveRelayAdapter();
|
|
379
385
|
}
|
|
380
386
|
|
|
381
|
-
#requireSession(sessionId) {
|
|
387
|
+
#requireSession(sessionId, userId = null) {
|
|
382
388
|
const session = this.getSession(sessionId);
|
|
383
389
|
if (!session) {
|
|
384
390
|
throw new Error('Voice session was not found.');
|
|
385
391
|
}
|
|
392
|
+
if (userId != null && session.userId != null && String(session.userId) !== String(userId)) {
|
|
393
|
+
throw new Error('Voice session access denied.');
|
|
394
|
+
}
|
|
386
395
|
return session;
|
|
387
396
|
}
|
|
388
397
|
|
|
@@ -27,11 +27,14 @@ function rejectUpgrade(socket, statusCode, message) {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
function remoteAddressFromRequest(req) {
|
|
30
|
-
const
|
|
31
|
-
if (
|
|
32
|
-
|
|
30
|
+
const directPeer = req.socket?.remoteAddress || 'unknown';
|
|
31
|
+
if (process.env.TRUST_PROXY === 'true' || process.env.TRUST_PROXY === '1') {
|
|
32
|
+
const forwarded = req.headers?.['x-forwarded-for'];
|
|
33
|
+
if (typeof forwarded === 'string' && forwarded.trim()) {
|
|
34
|
+
return forwarded.split(',')[0].trim();
|
|
35
|
+
}
|
|
33
36
|
}
|
|
34
|
-
return
|
|
37
|
+
return directPeer;
|
|
35
38
|
}
|
|
36
39
|
|
|
37
40
|
function createUpgradeLimiter() {
|
|
@@ -142,7 +145,7 @@ function createWearableVoiceSink(ws, voiceRuntimeManager) {
|
|
|
142
145
|
}
|
|
143
146
|
|
|
144
147
|
function bindWearableGateway(httpServer, app, sessionMiddleware) {
|
|
145
|
-
const wss = new WebSocketServer({ noServer: true });
|
|
148
|
+
const wss = new WebSocketServer({ noServer: true, maxPayload: 1 * 1024 * 1024 });
|
|
146
149
|
const allowUpgradeAttempt = createUpgradeLimiter();
|
|
147
150
|
|
|
148
151
|
httpServer.on('upgrade', (req, socket, head) => {
|
|
@@ -15,6 +15,11 @@ const MAX_QUERY_CHARS = 2000;
|
|
|
15
15
|
const DEFAULT_RATE_LIMIT_WINDOW_MS = 10 * 1000;
|
|
16
16
|
const DEFAULT_RATE_LIMIT_MAX = 30;
|
|
17
17
|
const RATE_LIMIT_OBSERVER_ENTRY_TTL_MS = 10 * 60 * 1000;
|
|
18
|
+
// Shared rate-limit state keyed by userId so that multiple sockets from the
|
|
19
|
+
// same authenticated user share a single budget rather than getting N × budget.
|
|
20
|
+
const userRateLimitStates = new Map();
|
|
21
|
+
const userSocketCounts = new Map();
|
|
22
|
+
|
|
18
23
|
const EVENT_RATE_LIMITS = Object.freeze({
|
|
19
24
|
'agent:run': { windowMs: 15 * 1000, max: 4 },
|
|
20
25
|
'agent:abort': { windowMs: 10 * 1000, max: 20 },
|
|
@@ -74,8 +79,7 @@ function resolveAgentFromPayload(userId, value) {
|
|
|
74
79
|
return resolveAgentId(userId, data?.agentId || data?.agent_id || null);
|
|
75
80
|
}
|
|
76
81
|
|
|
77
|
-
function createSocketRateLimiter() {
|
|
78
|
-
const state = new Map();
|
|
82
|
+
function createSocketRateLimiter(state = new Map()) {
|
|
79
83
|
return (eventName) => {
|
|
80
84
|
const config = EVENT_RATE_LIMITS[eventName] || {
|
|
81
85
|
windowMs: DEFAULT_RATE_LIMIT_WINDOW_MS,
|
|
@@ -184,7 +188,6 @@ function setupWebSocket(io, services) {
|
|
|
184
188
|
services.app.locals.getWebsocketRateLimitSnapshot = () => rateLimitObserver.snapshot();
|
|
185
189
|
}
|
|
186
190
|
io.on('connection', (socket) => {
|
|
187
|
-
const allowEvent = createSocketRateLimiter();
|
|
188
191
|
const session = socket.request.session;
|
|
189
192
|
if (!session?.userId) {
|
|
190
193
|
console.warn(`[WS] Rejecting unauthenticated socket ${socket.id}`);
|
|
@@ -195,6 +198,13 @@ function setupWebSocket(io, services) {
|
|
|
195
198
|
const userId = session.userId;
|
|
196
199
|
socket.join(`user:${userId}`);
|
|
197
200
|
|
|
201
|
+
// Share rate-limit budget across all sockets from the same authenticated user.
|
|
202
|
+
userSocketCounts.set(userId, (userSocketCounts.get(userId) || 0) + 1);
|
|
203
|
+
if (!userRateLimitStates.has(userId)) {
|
|
204
|
+
userRateLimitStates.set(userId, new Map());
|
|
205
|
+
}
|
|
206
|
+
const allowEvent = createSocketRateLimiter(userRateLimitStates.get(userId));
|
|
207
|
+
|
|
198
208
|
console.log(`[WS] User ${userId} connected (${socket.id})`);
|
|
199
209
|
|
|
200
210
|
// ── Agent Events ──
|
|
@@ -544,7 +554,7 @@ function setupWebSocket(io, services) {
|
|
|
544
554
|
await voiceRuntimeManager.beginInput(sessionId, {
|
|
545
555
|
mimeType: toOptionalString(data?.mimeType, 128),
|
|
546
556
|
turnId: toOptionalString(data?.turnId, 128),
|
|
547
|
-
});
|
|
557
|
+
}, userId);
|
|
548
558
|
} catch (err) {
|
|
549
559
|
console.error(`[WS] voice:input_start failed for user ${userId}:`, err);
|
|
550
560
|
socket.emit('voice:error', {
|
|
@@ -600,7 +610,7 @@ function setupWebSocket(io, services) {
|
|
|
600
610
|
mimeType: toOptionalString(data?.mimeType, 128),
|
|
601
611
|
turnId,
|
|
602
612
|
sequence,
|
|
603
|
-
});
|
|
613
|
+
}, userId);
|
|
604
614
|
socket.emit('voice:chunk_ack', {
|
|
605
615
|
sessionId,
|
|
606
616
|
turnId,
|
|
@@ -672,7 +682,7 @@ function setupWebSocket(io, services) {
|
|
|
672
682
|
finalSequence: toBoundedInt(data?.finalSequence, -1, -1, 1_000_000),
|
|
673
683
|
promptHint: toOptionalString(data?.promptHint, 2000),
|
|
674
684
|
metadata,
|
|
675
|
-
});
|
|
685
|
+
}, userId);
|
|
676
686
|
} catch (err) {
|
|
677
687
|
console.error(`[WS] voice:input_commit failed for user ${userId}:`, err);
|
|
678
688
|
socket.emit('voice:error', {
|
|
@@ -696,7 +706,7 @@ function setupWebSocket(io, services) {
|
|
|
696
706
|
if (!sessionId) {
|
|
697
707
|
return socket.emit('voice:error', { error: 'sessionId is required' });
|
|
698
708
|
}
|
|
699
|
-
await voiceRuntimeManager.interruptSession(sessionId);
|
|
709
|
+
await voiceRuntimeManager.interruptSession(sessionId, userId);
|
|
700
710
|
} catch (err) {
|
|
701
711
|
console.error(`[WS] voice:interrupt failed for user ${userId}:`, err);
|
|
702
712
|
socket.emit('voice:error', {
|
|
@@ -720,7 +730,7 @@ function setupWebSocket(io, services) {
|
|
|
720
730
|
if (!sessionId) {
|
|
721
731
|
return socket.emit('voice:error', { error: 'sessionId is required' });
|
|
722
732
|
}
|
|
723
|
-
await voiceRuntimeManager.closeSession(sessionId, 'client_closed');
|
|
733
|
+
await voiceRuntimeManager.closeSession(sessionId, 'client_closed', userId);
|
|
724
734
|
socket.data.voiceSessionIds?.delete(sessionId);
|
|
725
735
|
} catch (err) {
|
|
726
736
|
console.error(`[WS] voice:session_close failed for user ${userId}:`, err);
|
|
@@ -911,6 +921,14 @@ function setupWebSocket(io, services) {
|
|
|
911
921
|
// ── Disconnect ──
|
|
912
922
|
|
|
913
923
|
socket.on('disconnect', () => {
|
|
924
|
+
const remaining = (userSocketCounts.get(userId) || 1) - 1;
|
|
925
|
+
if (remaining <= 0) {
|
|
926
|
+
userSocketCounts.delete(userId);
|
|
927
|
+
userRateLimitStates.delete(userId);
|
|
928
|
+
} else {
|
|
929
|
+
userSocketCounts.set(userId, remaining);
|
|
930
|
+
}
|
|
931
|
+
|
|
914
932
|
const streamHub = services.streamHub || services.app?.locals?.streamHub;
|
|
915
933
|
if (streamHub && typeof streamHub.unsubscribeAll === 'function') {
|
|
916
934
|
void streamHub.unsubscribeAll(socket.id).catch((err) => {
|