neoagent 3.0.1-beta.5 → 3.0.1-beta.7
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/README.md +1 -2
- package/landing/index.html +2 -2
- package/package.json +6 -4
- package/server/db/database.js +8 -11
- package/server/db/sessions_db.js +8 -0
- package/server/http/middleware.js +4 -4
- package/server/public/.last_build_id +1 -1
- package/server/public/flutter_bootstrap.js +1 -1
- package/server/public/main.dart.js +4 -4
- package/server/routes/admin.js +34 -16
- package/server/routes/triggers.js +2 -2
- package/server/services/account/sessions.js +1 -9
- package/server/services/ai/loop/agent_engine_core.js +42 -0
- package/server/services/ai/loop/completion_judge.js +49 -0
- package/server/services/ai/loop/conversation_loop.js +149 -67
- package/server/services/ai/loopPolicy.js +18 -1
- package/server/services/ai/rate_limits.js +28 -4
- package/server/services/ai/tools.js +39 -2
- package/server/services/desktop/gateway.js +7 -4
- package/server/services/integrations/secrets.js +7 -4
- package/server/services/messaging/telnyx.js +3 -3
- package/server/services/messaging/whatsapp.js +1 -1
- package/server/services/recordings/manager.js +13 -7
- package/server/services/tasks/runtime.js +7 -1
- package/server/services/voice/runtimeManager.js +19 -10
- package/server/services/wearable/gateway.js +7 -4
- package/server/services/websocket.js +5 -5
package/README.md
CHANGED
|
@@ -43,9 +43,8 @@ npm install -g neoagent
|
|
|
43
43
|
neoagent install
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
-
<p>
|
|
46
|
+
<p align="center">
|
|
47
47
|
<a href="https://github.com/NeoLabs-Systems/NeoAgent/releases/latest"><img alt="Download macOS app" src="https://img.shields.io/badge/macOS_app-download-black?style=flat-square&logo=apple&logoColor=white"></a>
|
|
48
|
-
The macOS desktop app includes a graphical installer — open it and use the <strong>Server</strong> tab instead of the CLI.
|
|
49
48
|
</p>
|
|
50
49
|
|
|
51
50
|
Open `http://localhost:3333`, create the first account, and configure a model.
|
package/landing/index.html
CHANGED
|
@@ -910,7 +910,7 @@ p { margin: 0; text-wrap: pretty; }
|
|
|
910
910
|
<div class="wrap">
|
|
911
911
|
<div class="hero-grid">
|
|
912
912
|
<div class="hero-copy">
|
|
913
|
-
<div class="pill"><span class="tag">New</span> <b>NeoAgent is now in
|
|
913
|
+
<div class="pill"><span class="tag">New</span> <b>NeoAgent is now in beta</b></div>
|
|
914
914
|
<h1>
|
|
915
915
|
<span class="h1-line"><span class="w" style="--i:0">One</span> <span class="w" style="--i:1">agent</span> <span class="w" style="--i:2">for</span> <span class="w" style="--i:3">your</span></span>
|
|
916
916
|
<span class="h1-line"><span class="w" style="--i:4"><em>whole</em></span> <span class="w" style="--i:5"><em>digital</em></span> <span class="w" style="--i:6"><em>life</em></span></span>
|
|
@@ -1194,7 +1194,7 @@ p { margin: 0; text-wrap: pretty; }
|
|
|
1194
1194
|
<section class="section-tight" id="cta" data-screen-label="cta">
|
|
1195
1195
|
<div class="wrap">
|
|
1196
1196
|
<div class="final reveal">
|
|
1197
|
-
<span class="eyebrow" style="display:block;margin-bottom:20px;">
|
|
1197
|
+
<span class="eyebrow" style="display:block;margin-bottom:20px;">Beta · Limited spots</span>
|
|
1198
1198
|
<h2>The agent that just <em>handles it.</em></h2>
|
|
1199
1199
|
<p>Be first to put NeoAgent to work across your devices, channels, and life. We'll send your invite as spots open up.</p>
|
|
1200
1200
|
<div class="hero-actions" style="justify-content:center;">
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "neoagent",
|
|
3
|
-
"version": "3.0.1-beta.
|
|
3
|
+
"version": "3.0.1-beta.7",
|
|
4
4
|
"description": "Self-hosted AI agent for long-running tasks, automation, messaging, device control, and local memory",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"main": "server/index.js",
|
|
@@ -54,7 +54,8 @@
|
|
|
54
54
|
"benchmark:tokens": "node scripts/benchmark-token-cost.js",
|
|
55
55
|
"benchmark:memory": "node scripts/benchmark-memory.js",
|
|
56
56
|
"benchmark:memorybench:install": "node scripts/install-memorybench-provider.js",
|
|
57
|
-
"release": "npx semantic-release"
|
|
57
|
+
"release": "npx semantic-release",
|
|
58
|
+
"audit:ci": "npm audit --omit=dev --audit-level=high"
|
|
58
59
|
},
|
|
59
60
|
"repository": {
|
|
60
61
|
"type": "git",
|
|
@@ -103,7 +104,7 @@
|
|
|
103
104
|
"telnyx": "^5.51.0",
|
|
104
105
|
"tesseract.js": "^7.0.0",
|
|
105
106
|
"uuid": "^11.1.0",
|
|
106
|
-
"ws": "^8.
|
|
107
|
+
"ws": "^8.21.0"
|
|
107
108
|
},
|
|
108
109
|
"overrides": {
|
|
109
110
|
"axios": "^1.15.2",
|
|
@@ -113,7 +114,8 @@
|
|
|
113
114
|
"protobufjs": "^7.5.5",
|
|
114
115
|
"serialize-javascript": "^7.0.5",
|
|
115
116
|
"undici": "^6.24.0",
|
|
116
|
-
"webpackbar": "^7.0.0"
|
|
117
|
+
"webpackbar": "^7.0.0",
|
|
118
|
+
"ws": "^8.21.0"
|
|
117
119
|
},
|
|
118
120
|
"devDependencies": {
|
|
119
121
|
"@docusaurus/core": "3.10.0",
|
package/server/db/database.js
CHANGED
|
@@ -1879,7 +1879,7 @@ function migrateIntegrationProviderConfigsTable() {
|
|
|
1879
1879
|
}
|
|
1880
1880
|
|
|
1881
1881
|
function migrateIntegrationSecretStorage() {
|
|
1882
|
-
|
|
1882
|
+
const migrate = db.transaction(() => {
|
|
1883
1883
|
const connectionRows = db
|
|
1884
1884
|
.prepare('SELECT id, credentials_json FROM integration_connections')
|
|
1885
1885
|
.all();
|
|
@@ -1891,11 +1891,7 @@ function migrateIntegrationSecretStorage() {
|
|
|
1891
1891
|
if (!current || isEncryptedValue(current)) continue;
|
|
1892
1892
|
updateConnection.run(encryptValue(current), row.id);
|
|
1893
1893
|
}
|
|
1894
|
-
} catch {
|
|
1895
|
-
// Preserve startup even if a row cannot be re-encrypted.
|
|
1896
|
-
}
|
|
1897
1894
|
|
|
1898
|
-
try {
|
|
1899
1895
|
const stateRows = db
|
|
1900
1896
|
.prepare('SELECT id, code_verifier FROM integration_oauth_states')
|
|
1901
1897
|
.all();
|
|
@@ -1907,11 +1903,7 @@ function migrateIntegrationSecretStorage() {
|
|
|
1907
1903
|
if (!current || isEncryptedValue(current)) continue;
|
|
1908
1904
|
updateState.run(encryptValue(current), row.id);
|
|
1909
1905
|
}
|
|
1910
|
-
} catch {
|
|
1911
|
-
// Preserve startup even if a row cannot be re-encrypted.
|
|
1912
|
-
}
|
|
1913
1906
|
|
|
1914
|
-
try {
|
|
1915
1907
|
const providerRows = db
|
|
1916
1908
|
.prepare('SELECT id, config_json FROM integration_provider_configs')
|
|
1917
1909
|
.all();
|
|
@@ -1923,8 +1915,13 @@ function migrateIntegrationSecretStorage() {
|
|
|
1923
1915
|
if (!current || isEncryptedValue(current)) continue;
|
|
1924
1916
|
updateProviderConfig.run(encryptValue(current), row.id);
|
|
1925
1917
|
}
|
|
1926
|
-
}
|
|
1927
|
-
|
|
1918
|
+
});
|
|
1919
|
+
|
|
1920
|
+
try {
|
|
1921
|
+
migrate();
|
|
1922
|
+
} catch (err) {
|
|
1923
|
+
console.error('[DB] Integration secret migration failed — startup cannot continue with partially migrated secrets:', err.message);
|
|
1924
|
+
throw err;
|
|
1928
1925
|
}
|
|
1929
1926
|
}
|
|
1930
1927
|
|
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const session = require('express-session');
|
|
4
|
-
const Sqlite = require('better-sqlite3');
|
|
5
4
|
const SQLiteStore = require('better-sqlite3-session-store')(session);
|
|
6
5
|
const helmet = require('helmet');
|
|
7
6
|
const cors = require('cors');
|
|
8
|
-
const { DATA_DIR } = require('../../runtime/paths');
|
|
9
7
|
const { logRequestSummary } = require('../utils/logger');
|
|
10
8
|
const { getSessionSecret } = require('../services/account/session_secret');
|
|
11
9
|
|
|
12
|
-
const sessionsDb =
|
|
10
|
+
const sessionsDb = require('../db/sessions_db');
|
|
13
11
|
const LEGACY_SESSION_EXPIRE_FALLBACK = 0;
|
|
14
12
|
|
|
15
13
|
function boolEnv(name, fallback = false) {
|
|
@@ -89,7 +87,9 @@ function buildHelmetOptions({ secureCookies }) {
|
|
|
89
87
|
}
|
|
90
88
|
|
|
91
89
|
return {
|
|
92
|
-
strictTransportSecurity:
|
|
90
|
+
strictTransportSecurity: secureCookies
|
|
91
|
+
? { maxAge: Number(process.env.NEOAGENT_HSTS_MAX_AGE ?? 86400), includeSubDomains: false }
|
|
92
|
+
: false,
|
|
93
93
|
crossOriginOpenerPolicy: false,
|
|
94
94
|
crossOriginResourcePolicy: { policy: 'same-site' },
|
|
95
95
|
originAgentCluster: false,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
60f1b48c2d24c4a137efc1a6529d413f
|
|
@@ -37,6 +37,6 @@ _flutter.buildConfig = {"engineRevision":"77e2e94772b6eb43759e34ed1ad7da4674e19c
|
|
|
37
37
|
|
|
38
38
|
_flutter.loader.load({
|
|
39
39
|
serviceWorkerSettings: {
|
|
40
|
-
serviceWorkerVersion: "
|
|
40
|
+
serviceWorkerVersion: "2202305600" /* Flutter's service worker is deprecated and will be removed in a future Flutter release. */
|
|
41
41
|
}
|
|
42
42
|
});
|
|
@@ -135597,7 +135597,7 @@ r===$&&A.b()
|
|
|
135597
135597
|
p.push(A.jV(q,A.jd(!1,new A.a_(B.uM,A.db(new A.cC(B.jA,new A.a89(r,q),q),q,q),q),!1,B.I,!0),q,q,0,0,0,q))}r=!1
|
|
135598
135598
|
if(!s.ay)if(!s.ch){r=s.e
|
|
135599
135599
|
r===$&&A.b()
|
|
135600
|
-
r=B.b.u("
|
|
135600
|
+
r=B.b.u("mqj7s6w1-467cdbb").length!==0&&r.b}if(r){r=s.d
|
|
135601
135601
|
r===$&&A.b()
|
|
135602
135602
|
r=r.aP&&!r.ai?84:0
|
|
135603
135603
|
s=s.e
|
|
@@ -141319,7 +141319,7 @@ $S:0}
|
|
|
141319
141319
|
A.a_p.prototype={}
|
|
141320
141320
|
A.T7.prototype={
|
|
141321
141321
|
nd(a){var s=this
|
|
141322
|
-
if(B.b.u("
|
|
141322
|
+
if(B.b.u("mqj7s6w1-467cdbb").length===0||s.a!=null)return
|
|
141323
141323
|
s.B_()
|
|
141324
141324
|
s.a=A.ot(B.S2,new A.bde(s))},
|
|
141325
141325
|
B_(){var s=0,r=A.l(t.H),q,p=2,o=[],n=this,m,l,k,j,i,h,g,f
|
|
@@ -141337,7 +141337,7 @@ if(!t.f.b(k)){s=1
|
|
|
141337
141337
|
break}i=J.a3(k,"buildId")
|
|
141338
141338
|
h=i==null?null:B.b.u(J.p(i))
|
|
141339
141339
|
j=h==null?"":h
|
|
141340
|
-
if(J.bj(j)===0||J.e(j,"
|
|
141340
|
+
if(J.bj(j)===0||J.e(j,"mqj7s6w1-467cdbb")){s=1
|
|
141341
141341
|
break}n.b=!0
|
|
141342
141342
|
n.F()
|
|
141343
141343
|
p=2
|
|
@@ -141354,7 +141354,7 @@ case 2:return A.i(o.at(-1),r)}})
|
|
|
141354
141354
|
return A.k($async$B_,r)},
|
|
141355
141355
|
vI(){var s=0,r=A.l(t.H),q,p=2,o=[],n=this,m,l,k,j,i,h,g,f,e,d,c,b,a,a0,a1
|
|
141356
141356
|
var $async$vI=A.h(function(a2,a3){if(a2===1){o.push(a3)
|
|
141357
|
-
s=p}for(;;)switch(s){case 0:if(B.b.u("
|
|
141357
|
+
s=p}for(;;)switch(s){case 0:if(B.b.u("mqj7s6w1-467cdbb").length===0||n.c){s=1
|
|
141358
141358
|
break}n.c=!0
|
|
141359
141359
|
n.F()
|
|
141360
141360
|
p=4
|
package/server/routes/admin.js
CHANGED
|
@@ -64,8 +64,11 @@ router.get('/login', (req, res) => {
|
|
|
64
64
|
|
|
65
65
|
router.post('/api/login', loginLimiter, express.json(), async (req, res) => {
|
|
66
66
|
const { username, password } = req.body || {};
|
|
67
|
-
const expectedUsername = process.env.ADMIN_USERNAME
|
|
68
|
-
const expectedPassword = process.env.ADMIN_PASSWORD
|
|
67
|
+
const expectedUsername = process.env.ADMIN_USERNAME;
|
|
68
|
+
const expectedPassword = process.env.ADMIN_PASSWORD;
|
|
69
|
+
if (!expectedUsername || !expectedPassword) {
|
|
70
|
+
return res.status(503).json({ error: 'Admin interface is not configured. Set ADMIN_USERNAME and ADMIN_PASSWORD environment variables.' });
|
|
71
|
+
}
|
|
69
72
|
if (username !== expectedUsername || password !== expectedPassword) {
|
|
70
73
|
return res.status(401).json({ error: 'Invalid credentials' });
|
|
71
74
|
}
|
|
@@ -81,11 +84,14 @@ router.post('/api/login', loginLimiter, express.json(), async (req, res) => {
|
|
|
81
84
|
res.json({ ok: false, requiresTwoFactor: true });
|
|
82
85
|
});
|
|
83
86
|
}
|
|
84
|
-
req.session.
|
|
85
|
-
req.session.cookie.maxAge = ADMIN_SESSION_TTL;
|
|
86
|
-
req.session.save((err) => {
|
|
87
|
+
req.session.regenerate((err) => {
|
|
87
88
|
if (err) return res.status(500).json({ error: 'Session error' });
|
|
88
|
-
|
|
89
|
+
req.session.isAdmin = true;
|
|
90
|
+
req.session.cookie.maxAge = ADMIN_SESSION_TTL;
|
|
91
|
+
req.session.save((saveErr) => {
|
|
92
|
+
if (saveErr) return res.status(500).json({ error: 'Session error' });
|
|
93
|
+
res.json({ ok: true });
|
|
94
|
+
});
|
|
89
95
|
});
|
|
90
96
|
});
|
|
91
97
|
|
|
@@ -99,12 +105,14 @@ router.post('/api/2fa/verify', loginLimiter, express.json(), async (req, res) =>
|
|
|
99
105
|
const adminTwoFactor = require('../services/account/admin_two_factor');
|
|
100
106
|
const valid = await adminTwoFactor.verifyCode(code);
|
|
101
107
|
if (!valid) return res.status(401).json({ error: 'Invalid code — try again' });
|
|
102
|
-
req.session.
|
|
103
|
-
req.session.isAdmin = true;
|
|
104
|
-
req.session.cookie.maxAge = ADMIN_SESSION_TTL;
|
|
105
|
-
req.session.save((err) => {
|
|
108
|
+
req.session.regenerate((err) => {
|
|
106
109
|
if (err) return res.status(500).json({ error: 'Session error' });
|
|
107
|
-
|
|
110
|
+
req.session.isAdmin = true;
|
|
111
|
+
req.session.cookie.maxAge = ADMIN_SESSION_TTL;
|
|
112
|
+
req.session.save((saveErr) => {
|
|
113
|
+
if (saveErr) return res.status(500).json({ error: 'Session error' });
|
|
114
|
+
res.json({ ok: true });
|
|
115
|
+
});
|
|
108
116
|
});
|
|
109
117
|
} catch (err) {
|
|
110
118
|
res.status(500).json({ error: err.message });
|
|
@@ -559,7 +567,11 @@ router.delete('/api/users/:id', requireAdminAuth, (req, res) => {
|
|
|
559
567
|
const { DATA_DIR } = require('../../runtime/paths');
|
|
560
568
|
const { id } = req.params;
|
|
561
569
|
if (!id) return res.status(400).json({ error: 'Missing user id' });
|
|
570
|
+
if (!/^\d+$/.test(id)) return res.status(400).json({ error: 'Invalid user id' });
|
|
562
571
|
try {
|
|
572
|
+
const user = db.prepare('SELECT id FROM users WHERE id = ?').get(id);
|
|
573
|
+
if (!user) return res.status(404).json({ error: 'User not found' });
|
|
574
|
+
|
|
563
575
|
// Collect artifact paths before deletion
|
|
564
576
|
const artifacts = db.prepare('SELECT storage_path FROM artifacts WHERE user_id = ?').all(id);
|
|
565
577
|
|
|
@@ -585,16 +597,22 @@ router.delete('/api/users/:id', requireAdminAuth, (req, res) => {
|
|
|
585
597
|
});
|
|
586
598
|
erase(id);
|
|
587
599
|
|
|
588
|
-
// Clean up artifact files on disk
|
|
600
|
+
// Clean up artifact files on disk — containment-checked
|
|
601
|
+
const artifactsRoot = path.resolve(path.join(DATA_DIR, 'artifacts'));
|
|
589
602
|
for (const artifact of artifacts) {
|
|
590
603
|
try {
|
|
591
|
-
const abs = path.isAbsolute(artifact.storage_path)
|
|
604
|
+
const abs = path.resolve(path.isAbsolute(artifact.storage_path)
|
|
592
605
|
? artifact.storage_path
|
|
593
|
-
: path.join(DATA_DIR, artifact.storage_path);
|
|
594
|
-
|
|
606
|
+
: path.join(DATA_DIR, artifact.storage_path));
|
|
607
|
+
if (abs.startsWith(artifactsRoot + path.sep)) {
|
|
608
|
+
fs.rmSync(abs, { force: true });
|
|
609
|
+
}
|
|
595
610
|
} catch {}
|
|
596
611
|
}
|
|
597
|
-
|
|
612
|
+
const userArtifactDir = path.resolve(path.join(DATA_DIR, 'artifacts', id));
|
|
613
|
+
if (userArtifactDir.startsWith(artifactsRoot + path.sep)) {
|
|
614
|
+
try { fs.rmSync(userArtifactDir, { recursive: true, force: true }); } catch {}
|
|
615
|
+
}
|
|
598
616
|
|
|
599
617
|
res.json({ ok: true });
|
|
600
618
|
} catch (err) {
|
|
@@ -16,7 +16,7 @@ router.post('/geofence', async (req, res) => {
|
|
|
16
16
|
const userRow = db.prepare('SELECT id FROM users WHERE id = ?').get(req.session.userId);
|
|
17
17
|
if (!userRow) return res.status(401).json({ error: 'Unauthorized' });
|
|
18
18
|
|
|
19
|
-
console.log(`[Triggers] Geofence
|
|
19
|
+
console.log(`[Triggers] Geofence event for user ${req.session.userId}`);
|
|
20
20
|
|
|
21
21
|
res.json({ success: true, message: 'Geofence trigger processed' });
|
|
22
22
|
} catch (err) {
|
|
@@ -51,7 +51,7 @@ router.post('/notification', async (req, res) => {
|
|
|
51
51
|
const userRow = db.prepare('SELECT id FROM users WHERE id = ?').get(req.session.userId);
|
|
52
52
|
if (!userRow) return res.status(401).json({ error: 'Unauthorized' });
|
|
53
53
|
|
|
54
|
-
console.log(`[Triggers] Notification
|
|
54
|
+
console.log(`[Triggers] Notification event for user ${req.session.userId}`);
|
|
55
55
|
|
|
56
56
|
db.prepare(`
|
|
57
57
|
INSERT INTO notification_history (user_id, app_package, title, body, action_taken)
|
|
@@ -1,19 +1,11 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const crypto = require('crypto');
|
|
4
|
-
const Sqlite = require('better-sqlite3');
|
|
5
|
-
const { DATA_DIR } = require('../../../runtime/paths');
|
|
6
4
|
const db = require('../../db/database');
|
|
5
|
+
const sessionsDb = require('../../db/sessions_db');
|
|
7
6
|
const { clientIpFromRequest, lookupIpLocation } = require('./geoip');
|
|
8
7
|
const { getSessionSecret } = require('./session_secret');
|
|
9
8
|
|
|
10
|
-
const sessionsDb = new Sqlite(`${DATA_DIR}/sessions.db`);
|
|
11
|
-
try {
|
|
12
|
-
sessionsDb.exec('CREATE TABLE IF NOT EXISTS sessions (sid PRIMARY KEY, sess, expire)');
|
|
13
|
-
} catch {
|
|
14
|
-
// The primary session middleware owns schema normalization.
|
|
15
|
-
}
|
|
16
|
-
|
|
17
9
|
function sessionHash(sessionId) {
|
|
18
10
|
return crypto.createHmac('sha256', getSessionSecret()).update(String(sessionId || '')).digest('hex');
|
|
19
11
|
}
|
|
@@ -21,7 +21,9 @@ const { shouldAcceptTaskComplete } = require('../completion');
|
|
|
21
21
|
const { shortenRunId, summarizeForLog } = require('../logFormat');
|
|
22
22
|
const { runConversation } = require('./conversation_loop');
|
|
23
23
|
const {
|
|
24
|
+
buildChurnAssessmentPrompt,
|
|
24
25
|
buildCompletionDecisionPrompt,
|
|
26
|
+
normalizeChurnAssessment,
|
|
25
27
|
normalizeCompletionDecision,
|
|
26
28
|
resolveRunGoalContext,
|
|
27
29
|
} = require('./completion_judge');
|
|
@@ -812,6 +814,46 @@ class AgentEngine {
|
|
|
812
814
|
};
|
|
813
815
|
}
|
|
814
816
|
|
|
817
|
+
async assessChurnState({
|
|
818
|
+
provider,
|
|
819
|
+
providerName,
|
|
820
|
+
model,
|
|
821
|
+
messages,
|
|
822
|
+
analysis,
|
|
823
|
+
plan,
|
|
824
|
+
toolExecutions,
|
|
825
|
+
readOnlyCount,
|
|
826
|
+
alreadyRead,
|
|
827
|
+
iteration,
|
|
828
|
+
options,
|
|
829
|
+
}) {
|
|
830
|
+
const runMeta = options?.runId ? this.getRunMeta(options.runId) : null;
|
|
831
|
+
const goalContext = resolveRunGoalContext(runMeta, analysis, plan);
|
|
832
|
+
const response = await this.requestStructuredJson({
|
|
833
|
+
provider,
|
|
834
|
+
providerName,
|
|
835
|
+
model,
|
|
836
|
+
messages,
|
|
837
|
+
prompt: buildChurnAssessmentPrompt({
|
|
838
|
+
readOnlyCount,
|
|
839
|
+
alreadyRead,
|
|
840
|
+
goalContext,
|
|
841
|
+
toolExecutions,
|
|
842
|
+
iteration,
|
|
843
|
+
}),
|
|
844
|
+
maxTokens: 200,
|
|
845
|
+
normalize: (raw) => normalizeChurnAssessment(raw),
|
|
846
|
+
fallback: { assessment: 'churn', reason: 'churn assessment unavailable' },
|
|
847
|
+
reasoningEffort: this.getReasoningEffort(providerName, options),
|
|
848
|
+
telemetry: options,
|
|
849
|
+
phase: 'churn_assessment',
|
|
850
|
+
});
|
|
851
|
+
return {
|
|
852
|
+
assessment: response.value,
|
|
853
|
+
usage: response.usage,
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
|
|
815
857
|
async refreshConversationState({
|
|
816
858
|
conversationId,
|
|
817
859
|
runId,
|
|
@@ -241,12 +241,61 @@ function normalizeCompletionDecision(raw, fallbackStatus = 'continue') {
|
|
|
241
241
|
};
|
|
242
242
|
}
|
|
243
243
|
|
|
244
|
+
/**
|
|
245
|
+
* Ask the model to self-assess whether it is making genuine progress or
|
|
246
|
+
* spinning in analysis-paralysis. This replaces the hardcoded nudge threshold
|
|
247
|
+
* with an AI-controlled signal: the model knows its own plan better than any
|
|
248
|
+
* regex over tool names can infer.
|
|
249
|
+
*
|
|
250
|
+
* Intentionally lightweight — the response is capped at 200 tokens and the
|
|
251
|
+
* prompt is self-contained so the model can answer without re-reading history.
|
|
252
|
+
*/
|
|
253
|
+
function buildChurnAssessmentPrompt({
|
|
254
|
+
readOnlyCount,
|
|
255
|
+
alreadyRead,
|
|
256
|
+
goalContext,
|
|
257
|
+
toolExecutions,
|
|
258
|
+
iteration,
|
|
259
|
+
}) {
|
|
260
|
+
const lines = [
|
|
261
|
+
'Return JSON only.',
|
|
262
|
+
'Self-assess your current loop state — are you making genuine progress or spinning?',
|
|
263
|
+
'Schema: {"assessment":"progressing|churn|blocked","reason":"one short concrete sentence"}',
|
|
264
|
+
'',
|
|
265
|
+
`Context: ${readOnlyCount} consecutive iteration(s) with only read/search/inspect operations — no concrete state changes yet.`,
|
|
266
|
+
alreadyRead ? `Already inspected: ${alreadyRead}.` : '',
|
|
267
|
+
goalContext.effectiveGoal ? `Goal: ${goalContext.effectiveGoal}` : '',
|
|
268
|
+
goalContext.successCriteria.length > 0
|
|
269
|
+
? `Success criteria:\n${goalContext.successCriteria.map((c, i) => `${i + 1}. ${c}`).join('\n')}`
|
|
270
|
+
: '',
|
|
271
|
+
`Iteration: ${iteration}`,
|
|
272
|
+
`Recent tool evidence:\n${summarizeToolExecutions(toolExecutions, 6) || 'none'}`,
|
|
273
|
+
'',
|
|
274
|
+
'Assessment rules:',
|
|
275
|
+
'- "progressing": You are systematically gathering necessary context and the next concrete action is already determined — you know exactly what to do next.',
|
|
276
|
+
'- "churn": You are re-reading/re-searching information already in context, or exploring without a clear next concrete step. Accept the nudge and act.',
|
|
277
|
+
'- "blocked": No concrete action is available in this run. You have all the evidence needed to deliver a truthful final answer or a specific external blocker.',
|
|
278
|
+
];
|
|
279
|
+
return lines.filter(Boolean).join('\n');
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function normalizeChurnAssessment(raw) {
|
|
283
|
+
const allowed = new Set(['progressing', 'churn', 'blocked']);
|
|
284
|
+
const assessment = String(raw?.assessment || '').trim().toLowerCase();
|
|
285
|
+
return {
|
|
286
|
+
assessment: allowed.has(assessment) ? assessment : 'churn',
|
|
287
|
+
reason: String(raw?.reason || '').trim().slice(0, 300),
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
244
291
|
module.exports = {
|
|
292
|
+
buildChurnAssessmentPrompt,
|
|
245
293
|
buildCompletionDecisionPrompt,
|
|
246
294
|
buildGoalContractPrompt,
|
|
247
295
|
goalContractFromAnalysis,
|
|
248
296
|
goalContractFromPlan,
|
|
249
297
|
mergeGoalContracts,
|
|
298
|
+
normalizeChurnAssessment,
|
|
250
299
|
normalizeCompletionDecision,
|
|
251
300
|
normalizeGoalContract,
|
|
252
301
|
resolveRunGoalContext,
|
|
@@ -49,7 +49,7 @@ const {
|
|
|
49
49
|
selectDeliverableWorkflow,
|
|
50
50
|
validateDeliverableExecution,
|
|
51
51
|
} = require('../deliverables');
|
|
52
|
-
const { buildLoopPolicy, resolveToolResultLimits } = require('../loopPolicy');
|
|
52
|
+
const { buildLoopPolicy, resolveToolResultLimits, resolveChurnNudgeThreshold } = require('../loopPolicy');
|
|
53
53
|
const { globalHooks } = require('../hooks');
|
|
54
54
|
const { normalizeCompletionConfidence, shouldAcceptTaskComplete } = require('../completion');
|
|
55
55
|
const { enforceRateLimits } = require('../rate_limits');
|
|
@@ -541,7 +541,9 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
|
|
|
541
541
|
ensureDefaultAiSettings(userId, agentId);
|
|
542
542
|
const aiSettings = getAiSettings(userId, agentId);
|
|
543
543
|
|
|
544
|
-
enforceRateLimits(userId);
|
|
544
|
+
const { releaseReservation } = enforceRateLimits(userId);
|
|
545
|
+
|
|
546
|
+
try {
|
|
545
547
|
|
|
546
548
|
const runId = options.runId || uuidv4();
|
|
547
549
|
const conversationId = options.conversationId;
|
|
@@ -895,8 +897,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
|
|
|
895
897
|
let directAnswerEligible = false;
|
|
896
898
|
let analysisUsage = 0;
|
|
897
899
|
|
|
898
|
-
|
|
899
|
-
if (options.skipTaskAnalysis === true) {
|
|
900
|
+
if (options.skipTaskAnalysis === true) {
|
|
900
901
|
analysis = buildSkipTaskAnalysisResult(options.forceMode);
|
|
901
902
|
} else {
|
|
902
903
|
const analysisResult = await runWithModelFallback('task analysis', () => engine.analyzeTask({
|
|
@@ -1180,73 +1181,152 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
|
|
|
1180
1181
|
messages = steeringAtLoopStart.messages;
|
|
1181
1182
|
messages = sanitizeConversationMessages(messages);
|
|
1182
1183
|
|
|
1183
|
-
// Analysis-paralysis gate:
|
|
1184
|
-
//
|
|
1185
|
-
//
|
|
1184
|
+
// Analysis-paralysis gate (EVE-style: AI self-assessment instead of
|
|
1185
|
+
// hardcoded counters). The nudge threshold is derived from the goal
|
|
1186
|
+
// contract complexity/autonomy_level that the model set during task
|
|
1187
|
+
// analysis, making the loop budget sensitivity AI-controlled.
|
|
1188
|
+
//
|
|
1189
|
+
// Flow:
|
|
1190
|
+
// readOnlyCount >= maxConsecutiveReadOnlyIterations → hard safety net,
|
|
1191
|
+
// force wrap-up unconditionally (same as before).
|
|
1192
|
+
// readOnlyCount >= churnNudgeThreshold → ask the model to self-assess:
|
|
1193
|
+
// "progressing" → give grace (partial counter reset, loop continues).
|
|
1194
|
+
// "churn" → inject the nudge so the model can course-correct.
|
|
1195
|
+
// "blocked" → trigger the force wrap-up early (AI-authorised).
|
|
1186
1196
|
if (analysis.mode === 'execute' || analysis.mode === 'plan_execute') {
|
|
1187
1197
|
const readOnlyCount = engine.getRunMeta(runId)?.consecutiveReadOnlyIterations || 0;
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1198
|
+
|
|
1199
|
+
if (readOnlyCount > 0) {
|
|
1200
|
+
const runGoalCtx = resolveRunGoalContext(engine.getRunMeta(runId), analysis, plan);
|
|
1201
|
+
const churnNudgeThreshold = resolveChurnNudgeThreshold(runGoalCtx.goalContract);
|
|
1202
|
+
|
|
1203
|
+
let triggerForceWrapup = false;
|
|
1204
|
+
let forceWrapupSource = 'hard_limit';
|
|
1205
|
+
// Compute alreadyRead lazily — only needed at or above the nudge threshold.
|
|
1206
|
+
let alreadyRead = '';
|
|
1207
|
+
|
|
1208
|
+
if (readOnlyCount >= loopPolicy.maxConsecutiveReadOnlyIterations) {
|
|
1209
|
+
alreadyRead = summarizeReadTargets(toolExecutions);
|
|
1210
|
+
triggerForceWrapup = true;
|
|
1211
|
+
} else if (readOnlyCount >= churnNudgeThreshold) {
|
|
1212
|
+
alreadyRead = summarizeReadTargets(toolExecutions);
|
|
1213
|
+
let churnResult;
|
|
1214
|
+
try {
|
|
1215
|
+
churnResult = await engine.assessChurnState({
|
|
1216
|
+
provider,
|
|
1217
|
+
providerName,
|
|
1218
|
+
model,
|
|
1219
|
+
messages,
|
|
1220
|
+
analysis,
|
|
1221
|
+
plan,
|
|
1222
|
+
toolExecutions,
|
|
1223
|
+
readOnlyCount,
|
|
1224
|
+
alreadyRead,
|
|
1225
|
+
iteration,
|
|
1226
|
+
options: { ...options, triggerSource, runId, userId, agentId },
|
|
1227
|
+
});
|
|
1228
|
+
} catch (churnErr) {
|
|
1229
|
+
console.warn(`[Run ${shortenRunId(runId)}] churn_assessment failed: ${summarizeForLog(churnErr?.message || churnErr, 120)}`);
|
|
1230
|
+
churnResult = { assessment: { assessment: 'churn', reason: '' }, usage: 0 };
|
|
1231
|
+
}
|
|
1232
|
+
totalTokens += churnResult.usage || 0;
|
|
1233
|
+
engine.recordRunEvent(userId, runId, 'churn_assessment', {
|
|
1234
|
+
assessment: churnResult.assessment.assessment,
|
|
1235
|
+
reason: churnResult.assessment.reason,
|
|
1236
|
+
readOnlyCount,
|
|
1237
|
+
churnNudgeThreshold,
|
|
1238
|
+
iteration,
|
|
1239
|
+
}, { agentId });
|
|
1240
|
+
|
|
1241
|
+
const churnVerdict = churnResult.assessment.assessment;
|
|
1242
|
+
if (churnVerdict === 'blocked') {
|
|
1243
|
+
triggerForceWrapup = true;
|
|
1244
|
+
forceWrapupSource = 'ai_blocked';
|
|
1245
|
+
} else if (churnVerdict === 'progressing') {
|
|
1246
|
+
// Model is genuinely on track — partially reset so it gets
|
|
1247
|
+
// re-assessed after one more read-only turn rather than immediately.
|
|
1248
|
+
const iterMeta = engine.getRunMeta(runId);
|
|
1249
|
+
if (iterMeta) {
|
|
1250
|
+
iterMeta.consecutiveReadOnlyIterations = Math.max(0, churnNudgeThreshold - 1);
|
|
1251
|
+
}
|
|
1252
|
+
} else {
|
|
1253
|
+
// 'churn' — model acknowledges it is spinning; inject the nudge
|
|
1254
|
+
// so it can course-correct in the next iteration.
|
|
1255
|
+
messages.push({
|
|
1256
|
+
role: 'system',
|
|
1257
|
+
content: buildReadOnlyChurnGuidance({ readOnlyCount, alreadyRead }),
|
|
1258
|
+
});
|
|
1259
|
+
}
|
|
1236
1260
|
}
|
|
1237
|
-
|
|
1238
|
-
if (
|
|
1239
|
-
|
|
1240
|
-
|
|
1261
|
+
|
|
1262
|
+
if (triggerForceWrapup) {
|
|
1263
|
+
console.warn(
|
|
1264
|
+
`[Run ${shortenRunId(runId)}] no_progress_wrapup source=${forceWrapupSource} readOnlyCount=${readOnlyCount}`
|
|
1265
|
+
);
|
|
1266
|
+
engine.updateRunProgress(runId, {
|
|
1267
|
+
currentPhase: 'model',
|
|
1268
|
+
currentStep: 'model:no_progress_wrapup',
|
|
1269
|
+
currentTool: null,
|
|
1270
|
+
currentStepStartedAt: isoNow(),
|
|
1271
|
+
});
|
|
1272
|
+
let wrapTokens = 0;
|
|
1273
|
+
try {
|
|
1274
|
+
const wrapResponse = await withModelCallTimeout(
|
|
1275
|
+
provider.chat(
|
|
1276
|
+
sanitizeConversationMessages([
|
|
1277
|
+
...messages,
|
|
1278
|
+
{
|
|
1279
|
+
role: 'system',
|
|
1280
|
+
content: buildNoProgressWrapupPrompt({
|
|
1281
|
+
readOnlyCount,
|
|
1282
|
+
alreadyRead,
|
|
1283
|
+
platform: options?.source || null,
|
|
1284
|
+
}),
|
|
1285
|
+
},
|
|
1286
|
+
]),
|
|
1287
|
+
[],
|
|
1288
|
+
{ model, reasoningEffort: engine.getReasoningEffort(providerName, options) },
|
|
1289
|
+
),
|
|
1290
|
+
options,
|
|
1291
|
+
'No-progress wrap-up',
|
|
1292
|
+
);
|
|
1293
|
+
wrapTokens = wrapResponse.usage?.totalTokens || 0;
|
|
1294
|
+
lastContent = sanitizeModelOutput(wrapResponse.content || '', { model });
|
|
1295
|
+
} catch (wrapErr) {
|
|
1296
|
+
console.warn(`[Run ${shortenRunId(runId)}] no_progress_wrapup failed: ${summarizeForLog(wrapErr?.message || wrapErr, 180)}`);
|
|
1297
|
+
}
|
|
1298
|
+
totalTokens += wrapTokens;
|
|
1299
|
+
const usableWrap = normalizeOutgoingMessage(lastContent, options?.source || null);
|
|
1300
|
+
if (!usableWrap) {
|
|
1301
|
+
lastContent = buildDeterministicMessagingFallback({ failedStepCount, stepIndex, toolExecutions });
|
|
1302
|
+
}
|
|
1303
|
+
messages.push({ role: 'assistant', content: lastContent });
|
|
1304
|
+
if (conversationId) {
|
|
1305
|
+
db.prepare('INSERT INTO conversation_messages (conversation_id, role, content, tokens) VALUES (?, ?, ?, ?)')
|
|
1306
|
+
.run(conversationId, 'assistant', lastContent, usableWrap ? wrapTokens : 0);
|
|
1307
|
+
}
|
|
1308
|
+
engine.recordRunEvent(userId, runId, 'no_progress_wrapup_delivered', {
|
|
1309
|
+
iteration,
|
|
1310
|
+
readOnlyCount,
|
|
1311
|
+
source: usableWrap ? 'model' : 'deterministic',
|
|
1312
|
+
forceWrapupSource,
|
|
1313
|
+
stepIndex,
|
|
1314
|
+
}, { agentId });
|
|
1315
|
+
// This wrap-up is a forced, tool-less terminal answer: the model had no
|
|
1316
|
+
// way to call send_message itself. On automatic background runs the plain
|
|
1317
|
+
// result is normally gated, which would silently drop this. Mark it so the
|
|
1318
|
+
// task runtime delivers it — a stuck/blocked scheduled task must still
|
|
1319
|
+
// surface its result instead of going silent.
|
|
1320
|
+
if (
|
|
1321
|
+
(triggerSource === 'schedule' || triggerSource === 'tasks')
|
|
1322
|
+
&& options.deliveryState
|
|
1323
|
+
&& !engine.activeRuns.get(runId)?.messagingSent
|
|
1324
|
+
) {
|
|
1325
|
+
options.deliveryState.terminalWrapup = true;
|
|
1326
|
+
}
|
|
1327
|
+
directAnswerEligible = true;
|
|
1328
|
+
break;
|
|
1241
1329
|
}
|
|
1242
|
-
engine.recordRunEvent(userId, runId, 'no_progress_wrapup_delivered', {
|
|
1243
|
-
iteration,
|
|
1244
|
-
readOnlyCount,
|
|
1245
|
-
source: usableWrap ? 'model' : 'deterministic',
|
|
1246
|
-
stepIndex,
|
|
1247
|
-
}, { agentId });
|
|
1248
|
-
directAnswerEligible = true;
|
|
1249
|
-
break;
|
|
1250
1330
|
}
|
|
1251
1331
|
}
|
|
1252
1332
|
|
|
@@ -2550,6 +2630,8 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
|
|
|
2550
2630
|
}
|
|
2551
2631
|
|
|
2552
2632
|
throw err;
|
|
2633
|
+
} finally {
|
|
2634
|
+
releaseReservation();
|
|
2553
2635
|
}
|
|
2554
2636
|
}
|
|
2555
2637
|
|
|
@@ -176,4 +176,21 @@ function resolveToolResultLimits(toolName, policy) {
|
|
|
176
176
|
return { softLimit: soft, hardLimit: hard };
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
-
|
|
179
|
+
/**
|
|
180
|
+
* Resolve the read-only churn nudge threshold from the current goal contract.
|
|
181
|
+
* The AI itself sets complexity and autonomy_level during task analysis, so
|
|
182
|
+
* this threshold is indirectly AI-controlled rather than a hardcoded constant.
|
|
183
|
+
*
|
|
184
|
+
* Simple tasks get nudged sooner (2 read-only turns) because exploration is
|
|
185
|
+
* rarely needed. Complex/high-autonomy tasks get more latitude (5 turns) before
|
|
186
|
+
* the churn self-assessment fires.
|
|
187
|
+
*/
|
|
188
|
+
function resolveChurnNudgeThreshold(goalContract) {
|
|
189
|
+
const complexity = String(goalContract?.complexity || 'standard').toLowerCase();
|
|
190
|
+
const autonomyLevel = String(goalContract?.autonomyLevel || goalContract?.autonomy_level || 'normal').toLowerCase();
|
|
191
|
+
if (complexity === 'simple') return 2;
|
|
192
|
+
if (complexity === 'complex' || autonomyLevel === 'high') return 5;
|
|
193
|
+
return 3;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
module.exports = { buildLoopPolicy, getToolCategory, resolveToolResultLimits, resolveChurnNudgeThreshold };
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
const db = require('../../db/database');
|
|
4
4
|
|
|
5
|
+
// In-process reservation map: userId -> reserved token count for in-flight runs.
|
|
6
|
+
// Prevents concurrent run starts from collectively bypassing the per-user budget.
|
|
7
|
+
const _reservations = new Map();
|
|
8
|
+
|
|
5
9
|
const DEFAULT_RATE_LIMIT_4H = 2_500_000;
|
|
6
10
|
const DEFAULT_RATE_LIMIT_WEEKLY = 10_000_000;
|
|
7
11
|
|
|
@@ -83,7 +87,7 @@ function nextDecreaseAt(rows, durationMs, usage, limit) {
|
|
|
83
87
|
return null;
|
|
84
88
|
}
|
|
85
89
|
|
|
86
|
-
function getRateLimitSnapshot(userId) {
|
|
90
|
+
function getRateLimitSnapshot(userId, { includeReservations = false } = {}) {
|
|
87
91
|
const userLimits = db.prepare(
|
|
88
92
|
'SELECT rate_limit_4h, rate_limit_weekly FROM users WHERE id = ?',
|
|
89
93
|
).get(userId);
|
|
@@ -100,6 +104,7 @@ function getRateLimitSnapshot(userId) {
|
|
|
100
104
|
fourHourIsCustom: customFourHour != null,
|
|
101
105
|
weeklyIsCustom: customWeekly != null,
|
|
102
106
|
};
|
|
107
|
+
const reserved = includeReservations ? (_reservations.get(String(userId)) || 0) : 0;
|
|
103
108
|
const usage = {};
|
|
104
109
|
const remaining = {};
|
|
105
110
|
const reached = {};
|
|
@@ -107,7 +112,8 @@ function getRateLimitSnapshot(userId) {
|
|
|
107
112
|
|
|
108
113
|
for (const [windowKey, config] of Object.entries(WINDOWS)) {
|
|
109
114
|
const rows = usageRows(userId, config.durationMs);
|
|
110
|
-
const
|
|
115
|
+
const committed = rows.reduce((total, row) => total + Number(row.tokens || 0), 0);
|
|
116
|
+
const used = committed + reserved;
|
|
111
117
|
const limit = limits[windowKey];
|
|
112
118
|
usage[windowKey] = used;
|
|
113
119
|
remaining[windowKey] = limit == null ? null : Math.max(0, limit - used);
|
|
@@ -130,14 +136,31 @@ function getRateLimitSnapshot(userId) {
|
|
|
130
136
|
}
|
|
131
137
|
|
|
132
138
|
function enforceRateLimits(userId) {
|
|
133
|
-
const snapshot = getRateLimitSnapshot(userId);
|
|
139
|
+
const snapshot = getRateLimitSnapshot(userId, { includeReservations: true });
|
|
134
140
|
if (snapshot.reached.fourHour) {
|
|
135
141
|
throw new RateLimitExceededError('fourHour', snapshot);
|
|
136
142
|
}
|
|
137
143
|
if (snapshot.reached.weekly) {
|
|
138
144
|
throw new RateLimitExceededError('weekly', snapshot);
|
|
139
145
|
}
|
|
140
|
-
|
|
146
|
+
// Reserve a placeholder so concurrent starts see this run as in-flight.
|
|
147
|
+
// The reservation is released (or reconciled) when the run completes.
|
|
148
|
+
const key = String(userId);
|
|
149
|
+
const limits = snapshot.limits;
|
|
150
|
+
const reserve = Math.max(limits.fourHour || 0, limits.weekly || 0, 1);
|
|
151
|
+
_reservations.set(key, (_reservations.get(key) || 0) + reserve);
|
|
152
|
+
return { snapshot, releaseReservation: () => releaseReservation(userId, reserve) };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function releaseReservation(userId, amount) {
|
|
156
|
+
const key = String(userId);
|
|
157
|
+
const current = _reservations.get(key) || 0;
|
|
158
|
+
const next = current - amount;
|
|
159
|
+
if (next <= 0) {
|
|
160
|
+
_reservations.delete(key);
|
|
161
|
+
} else {
|
|
162
|
+
_reservations.set(key, next);
|
|
163
|
+
}
|
|
141
164
|
}
|
|
142
165
|
|
|
143
166
|
module.exports = {
|
|
@@ -147,4 +170,5 @@ module.exports = {
|
|
|
147
170
|
configuredDefaultLimits,
|
|
148
171
|
enforceRateLimits,
|
|
149
172
|
getRateLimitSnapshot,
|
|
173
|
+
releaseReservation,
|
|
150
174
|
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const { analyzeImageForUser } = require('./imageAnalysis');
|
|
4
|
+
const { isPrivateHost } = require('../../utils/cloud-security');
|
|
4
5
|
const db = require('../../db/database');
|
|
5
6
|
const { DATA_DIR } = require('../../../runtime/paths');
|
|
6
7
|
const { isMainAgent } = require('../agents/manager');
|
|
@@ -1737,6 +1738,9 @@ async function executeTool(toolName, args, context, engine) {
|
|
|
1737
1738
|
}
|
|
1738
1739
|
|
|
1739
1740
|
case 'browser_navigate': {
|
|
1741
|
+
const { validateCloudUrl } = require('../../utils/cloud-security');
|
|
1742
|
+
const urlCheck = validateCloudUrl(args.url);
|
|
1743
|
+
if (!urlCheck.allowed) return { error: 'URL is not allowed: blocked scheme or private/internal network address.' };
|
|
1740
1744
|
const { provider, backend } = await bc();
|
|
1741
1745
|
if (!provider) return { error: 'Browser controller not available' };
|
|
1742
1746
|
return { ...await provider.navigate(args.url, {
|
|
@@ -2578,6 +2582,22 @@ async function executeTool(toolName, args, context, engine) {
|
|
|
2578
2582
|
}
|
|
2579
2583
|
|
|
2580
2584
|
case 'http_request': {
|
|
2585
|
+
let parsedUrl;
|
|
2586
|
+
try { parsedUrl = new URL(args.url); } catch {
|
|
2587
|
+
return { error: 'Invalid URL' };
|
|
2588
|
+
}
|
|
2589
|
+
const scheme = parsedUrl.protocol.replace(/:$/, '').toLowerCase();
|
|
2590
|
+
if (!['http', 'https'].includes(scheme)) {
|
|
2591
|
+
return { error: 'URL scheme not allowed. Only http and https are permitted.' };
|
|
2592
|
+
}
|
|
2593
|
+
const h = parsedUrl.hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
|
2594
|
+
if (h === 'localhost' || h === '127.0.0.1' || h === '::1' || h.endsWith('.localhost')) {
|
|
2595
|
+
return { error: 'Loopback addresses are not permitted.' };
|
|
2596
|
+
}
|
|
2597
|
+
const allowPrivate = process.env.NEOAGENT_HTTP_ALLOW_PRIVATE !== 'false';
|
|
2598
|
+
if (!allowPrivate && isPrivateHost(parsedUrl.hostname)) {
|
|
2599
|
+
return { error: 'Private/internal network addresses are not permitted.' };
|
|
2600
|
+
}
|
|
2581
2601
|
const controller = new AbortController();
|
|
2582
2602
|
const timeoutMs = args.timeout_ms || 30000;
|
|
2583
2603
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
@@ -2594,11 +2614,28 @@ async function executeTool(toolName, args, context, engine) {
|
|
|
2594
2614
|
}
|
|
2595
2615
|
}
|
|
2596
2616
|
const res = await fetch(args.url, options);
|
|
2597
|
-
const
|
|
2617
|
+
const MAX_BODY = 512 * 1024;
|
|
2618
|
+
const reader = res.body.getReader();
|
|
2619
|
+
const chunks = [];
|
|
2620
|
+
let total = 0;
|
|
2621
|
+
let truncated = false;
|
|
2622
|
+
while (true) {
|
|
2623
|
+
const { done, value } = await reader.read();
|
|
2624
|
+
if (done) break;
|
|
2625
|
+
total += value.length;
|
|
2626
|
+
if (total > MAX_BODY) {
|
|
2627
|
+
const take = MAX_BODY - (total - value.length);
|
|
2628
|
+
if (take > 0) chunks.push(value.slice(0, take));
|
|
2629
|
+
truncated = true;
|
|
2630
|
+
break;
|
|
2631
|
+
}
|
|
2632
|
+
chunks.push(value);
|
|
2633
|
+
}
|
|
2634
|
+
const text = Buffer.concat(chunks.map(c => Buffer.from(c))).toString('utf-8');
|
|
2598
2635
|
return {
|
|
2599
2636
|
status: res.status,
|
|
2600
2637
|
headers: Object.fromEntries(res.headers.entries()),
|
|
2601
|
-
body:
|
|
2638
|
+
body: truncated ? text + '\n...[truncated]' : text,
|
|
2602
2639
|
};
|
|
2603
2640
|
} catch (err) {
|
|
2604
2641
|
if (err.name === 'AbortError') return { error: `Request timed out after ${timeoutMs} ms` };
|
|
@@ -28,11 +28,14 @@ function rejectUpgrade(socket, statusCode, message) {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
function remoteAddressFromRequest(req) {
|
|
31
|
-
const
|
|
32
|
-
if (
|
|
33
|
-
|
|
31
|
+
const directPeer = req.socket?.remoteAddress || 'unknown';
|
|
32
|
+
if (process.env.TRUST_PROXY === 'true' || process.env.TRUST_PROXY === '1') {
|
|
33
|
+
const forwarded = req.headers?.['x-forwarded-for'];
|
|
34
|
+
if (typeof forwarded === 'string' && forwarded.trim()) {
|
|
35
|
+
return forwarded.split(',')[0].trim();
|
|
36
|
+
}
|
|
34
37
|
}
|
|
35
|
-
return
|
|
38
|
+
return directPeer;
|
|
36
39
|
}
|
|
37
40
|
|
|
38
41
|
function createUpgradeThrottleObserver() {
|
|
@@ -5,13 +5,16 @@ const crypto = require('crypto');
|
|
|
5
5
|
const SECRET_PREFIX = 'enc:v1:';
|
|
6
6
|
|
|
7
7
|
function getSecretMaterial() {
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
// Prefer a dedicated data-encryption key so rotating SESSION_SECRET
|
|
9
|
+
// does not invalidate persisted credentials. Fall back to SESSION_SECRET
|
|
10
|
+
// for existing deployments that haven't set NEOAGENT_ENCRYPTION_KEY yet.
|
|
11
|
+
const key = String(process.env.NEOAGENT_ENCRYPTION_KEY || process.env.SESSION_SECRET || '').trim();
|
|
12
|
+
if (!key) {
|
|
10
13
|
throw new Error(
|
|
11
|
-
'Official integrations require SESSION_SECRET to be configured.',
|
|
14
|
+
'Official integrations require NEOAGENT_ENCRYPTION_KEY (or SESSION_SECRET) to be configured.',
|
|
12
15
|
);
|
|
13
16
|
}
|
|
14
|
-
return
|
|
17
|
+
return key;
|
|
15
18
|
}
|
|
16
19
|
|
|
17
20
|
function getKey() {
|
|
@@ -233,7 +233,7 @@ class TelnyxVoicePlatform extends BasePlatform {
|
|
|
233
233
|
if (!this._hasSession(ccId)) return;
|
|
234
234
|
const sess = this._session(ccId);
|
|
235
235
|
if (!sess.awaitingSecret) return;
|
|
236
|
-
console.log(`[TelnyxVoice] Secret code timeout for ${ccId.slice(-8)}
|
|
236
|
+
console.log(`[TelnyxVoice] Secret code timeout for ${ccId.slice(-8)}`);
|
|
237
237
|
this._banNumber(sess.callerNumber);
|
|
238
238
|
this._endSession(ccId);
|
|
239
239
|
try { await this._hangupCall(ccId); } catch {}
|
|
@@ -535,7 +535,7 @@ class TelnyxVoicePlatform extends BasePlatform {
|
|
|
535
535
|
if (this.voiceSecret && sess.secretDigits.length >= this.voiceSecret.length) {
|
|
536
536
|
this._cancelSecretTimer(ccId);
|
|
537
537
|
if (sess.secretDigits === this.voiceSecret) {
|
|
538
|
-
console.log(`[TelnyxVoice] Secret accepted for ${ccId.slice(-8)}
|
|
538
|
+
console.log(`[TelnyxVoice] Secret accepted for ${ccId.slice(-8)}`);
|
|
539
539
|
sess.awaitingSecret = false;
|
|
540
540
|
sess.secretDigits = '';
|
|
541
541
|
sess.isProcessing = true;
|
|
@@ -613,7 +613,7 @@ class TelnyxVoicePlatform extends BasePlatform {
|
|
|
613
613
|
break;
|
|
614
614
|
}
|
|
615
615
|
|
|
616
|
-
console.log(`[TelnyxVoice] Transcript
|
|
616
|
+
console.log(`[TelnyxVoice] Transcript received for ${ccId.slice(-8)} (${transcript.length} chars)`);
|
|
617
617
|
|
|
618
618
|
// Mark as thinking — gates call.playback.ended so think-audio events
|
|
619
619
|
// don't corrupt session state while the agent is processing.
|
|
@@ -210,7 +210,7 @@ class WhatsAppPlatform extends BasePlatform {
|
|
|
210
210
|
response_format: 'text'
|
|
211
211
|
});
|
|
212
212
|
content = (typeof transcription === 'string' ? transcription : transcription?.text || '').trim() || '[Voice Note - empty audio]';
|
|
213
|
-
console.log(`[WhatsApp] Voice note transcribed
|
|
213
|
+
console.log(`[WhatsApp] Voice note transcribed (${content.length} chars)`);
|
|
214
214
|
} catch (transcribeErr) {
|
|
215
215
|
console.error('[WhatsApp] Audio transcription failed:', transcribeErr.message);
|
|
216
216
|
content = '[Voice Note - transcription failed]';
|
|
@@ -29,6 +29,12 @@ function ensureRecordingDirs() {
|
|
|
29
29
|
fs.mkdirSync(RECORDINGS_DIR, { recursive: true });
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
function validateSourceKey(key) {
|
|
33
|
+
if (!key || !/^[a-z0-9][a-z0-9_-]*$/.test(key)) {
|
|
34
|
+
throw new Error(`Invalid sourceKey "${key}": only lowercase alphanumeric, hyphens, and underscores are allowed.`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
32
38
|
class RecordingManager {
|
|
33
39
|
constructor(io) {
|
|
34
40
|
this.io = io;
|
|
@@ -241,10 +247,8 @@ class RecordingManager {
|
|
|
241
247
|
throw new Error('Recording session is not accepting more chunks.');
|
|
242
248
|
}
|
|
243
249
|
|
|
244
|
-
const sourceKey = `${metadata.sourceKey || ''}`.trim();
|
|
245
|
-
|
|
246
|
-
throw new Error('sourceKey is required.');
|
|
247
|
-
}
|
|
250
|
+
const sourceKey = `${metadata.sourceKey || ''}`.trim().toLowerCase();
|
|
251
|
+
validateSourceKey(sourceKey);
|
|
248
252
|
|
|
249
253
|
const source = this.#getSessionSourceByKey(sessionId, sourceKey);
|
|
250
254
|
|
|
@@ -289,6 +293,10 @@ class RecordingManager {
|
|
|
289
293
|
const endMs = Math.max(startMs, Number(metadata.endMs) || startMs);
|
|
290
294
|
const extension = this.#extensionForMime(mimeType);
|
|
291
295
|
const fileDir = path.join(RECORDINGS_DIR, `user-${userId}`, sessionId, sourceKey);
|
|
296
|
+
const sessionRoot = path.resolve(path.join(RECORDINGS_DIR, `user-${userId}`, sessionId));
|
|
297
|
+
if (!path.resolve(fileDir).startsWith(sessionRoot + path.sep)) {
|
|
298
|
+
throw new Error('Invalid recording path');
|
|
299
|
+
}
|
|
292
300
|
fs.mkdirSync(fileDir, { recursive: true });
|
|
293
301
|
const filePath = path.join(fileDir, `${String(sequenceIndex).padStart(6, '0')}${extension}`);
|
|
294
302
|
const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
|
|
@@ -1113,9 +1121,7 @@ class RecordingManager {
|
|
|
1113
1121
|
|
|
1114
1122
|
return inputs.map((item, index) => {
|
|
1115
1123
|
const sourceKey = `${item?.sourceKey || item?.id || `source-${index}`}`.trim().toLowerCase();
|
|
1116
|
-
|
|
1117
|
-
throw new Error('Every recording source needs a sourceKey.');
|
|
1118
|
-
}
|
|
1124
|
+
validateSourceKey(sourceKey);
|
|
1119
1125
|
if (seen.has(sourceKey)) {
|
|
1120
1126
|
throw new Error(`Duplicate recording source: ${sourceKey}`);
|
|
1121
1127
|
}
|
|
@@ -801,7 +801,13 @@ class TaskRuntime {
|
|
|
801
801
|
if (!targets.length) return null;
|
|
802
802
|
const resultText = stringifyTaskResult(result).trim();
|
|
803
803
|
const resultLooksLikeError = Boolean(result?.error);
|
|
804
|
-
|
|
804
|
+
// A forced terminal wrap-up (read-only/blocked hard-stop) is the model's final
|
|
805
|
+
// answer produced WITHOUT the ability to call send_message itself. Gating it
|
|
806
|
+
// would silently drop a stuck scheduled task's result, so deliver it even on an
|
|
807
|
+
// automatic run. Ordinary mid-run plain text (model had send_message available
|
|
808
|
+
// and chose not to use it) is still gated below.
|
|
809
|
+
const forcedTerminal = deliveryState?.terminalWrapup === true && Boolean(resultText);
|
|
810
|
+
if (!allowPlainResultFallback && !resultLooksLikeError && !forcedTerminal) {
|
|
805
811
|
// Automatic run produced substantive text but never made an explicit
|
|
806
812
|
// send_message decision (a deliberate no_response would have short-circuited
|
|
807
813
|
// above). We suppress to avoid recurring-check spam, but surface it so a
|
|
@@ -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() {
|
|
@@ -544,7 +544,7 @@ function setupWebSocket(io, services) {
|
|
|
544
544
|
await voiceRuntimeManager.beginInput(sessionId, {
|
|
545
545
|
mimeType: toOptionalString(data?.mimeType, 128),
|
|
546
546
|
turnId: toOptionalString(data?.turnId, 128),
|
|
547
|
-
});
|
|
547
|
+
}, userId);
|
|
548
548
|
} catch (err) {
|
|
549
549
|
console.error(`[WS] voice:input_start failed for user ${userId}:`, err);
|
|
550
550
|
socket.emit('voice:error', {
|
|
@@ -600,7 +600,7 @@ function setupWebSocket(io, services) {
|
|
|
600
600
|
mimeType: toOptionalString(data?.mimeType, 128),
|
|
601
601
|
turnId,
|
|
602
602
|
sequence,
|
|
603
|
-
});
|
|
603
|
+
}, userId);
|
|
604
604
|
socket.emit('voice:chunk_ack', {
|
|
605
605
|
sessionId,
|
|
606
606
|
turnId,
|
|
@@ -672,7 +672,7 @@ function setupWebSocket(io, services) {
|
|
|
672
672
|
finalSequence: toBoundedInt(data?.finalSequence, -1, -1, 1_000_000),
|
|
673
673
|
promptHint: toOptionalString(data?.promptHint, 2000),
|
|
674
674
|
metadata,
|
|
675
|
-
});
|
|
675
|
+
}, userId);
|
|
676
676
|
} catch (err) {
|
|
677
677
|
console.error(`[WS] voice:input_commit failed for user ${userId}:`, err);
|
|
678
678
|
socket.emit('voice:error', {
|
|
@@ -696,7 +696,7 @@ function setupWebSocket(io, services) {
|
|
|
696
696
|
if (!sessionId) {
|
|
697
697
|
return socket.emit('voice:error', { error: 'sessionId is required' });
|
|
698
698
|
}
|
|
699
|
-
await voiceRuntimeManager.interruptSession(sessionId);
|
|
699
|
+
await voiceRuntimeManager.interruptSession(sessionId, userId);
|
|
700
700
|
} catch (err) {
|
|
701
701
|
console.error(`[WS] voice:interrupt failed for user ${userId}:`, err);
|
|
702
702
|
socket.emit('voice:error', {
|
|
@@ -720,7 +720,7 @@ function setupWebSocket(io, services) {
|
|
|
720
720
|
if (!sessionId) {
|
|
721
721
|
return socket.emit('voice:error', { error: 'sessionId is required' });
|
|
722
722
|
}
|
|
723
|
-
await voiceRuntimeManager.closeSession(sessionId, 'client_closed');
|
|
723
|
+
await voiceRuntimeManager.closeSession(sessionId, 'client_closed', userId);
|
|
724
724
|
socket.data.voiceSessionIds?.delete(sessionId);
|
|
725
725
|
} catch (err) {
|
|
726
726
|
console.error(`[WS] voice:session_close failed for user ${userId}:`, err);
|