neoagent 3.2.1-beta.6 → 3.2.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/package.json +1 -1
- 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/services/ai/capabilityHealth.js +62 -96
- package/server/services/ai/loop/conversation_loop.js +19 -3
- package/server/services/ai/model_failure_cache.js +69 -0
- package/server/services/ai/models.js +1 -1
- package/server/services/ai/provider_selector.js +58 -3
- package/server/services/ai/providers/google.js +15 -1
- package/server/services/runtime/docker-vm-manager.js +9 -0
- package/server/services/runtime/guest_bootstrap.js +7 -0
- package/server/services/runtime/guest_image.js +39 -9
- package/server/services/runtime/manager.js +35 -0
package/package.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
4e3d49d31cbb561d89adf60e16759ebe
|
|
@@ -37,6 +37,6 @@ _flutter.buildConfig = {"engineRevision":"69c8c61792f04cc809dfef0c910414fb9afc06
|
|
|
37
37
|
|
|
38
38
|
_flutter.loader.load({
|
|
39
39
|
serviceWorkerSettings: {
|
|
40
|
-
serviceWorkerVersion: "
|
|
40
|
+
serviceWorkerVersion: "3827219452" /* Flutter's service worker is deprecated and will be removed in a future Flutter release. */
|
|
41
41
|
}
|
|
42
42
|
});
|
|
@@ -144506,7 +144506,7 @@ if(r){r=s.d
|
|
|
144506
144506
|
r===$&&A.b()
|
|
144507
144507
|
p.push(A.ju(q,A.jx(!1,new A.Y(B.wP,A.cU(new A.cH(B.kE,new A.acU(r,q),q),q,q),q),!1,B.J,!0),q,q,0,0,0,q))}if(!s.ay){r=s.e
|
|
144508
144508
|
r===$&&A.b()
|
|
144509
|
-
r=B.b.t("
|
|
144509
|
+
r=B.b.t("mry3rvmk-b952e5d").length!==0&&r.b}else r=!1
|
|
144510
144510
|
if(r){r=s.d
|
|
144511
144511
|
r===$&&A.b()
|
|
144512
144512
|
r=r.aU&&!r.ag?84:0
|
|
@@ -150393,7 +150393,7 @@ $S:0}
|
|
|
150393
150393
|
A.a3j.prototype={}
|
|
150394
150394
|
A.WC.prototype={
|
|
150395
150395
|
r9(a){var s=this
|
|
150396
|
-
if(B.b.t("
|
|
150396
|
+
if(B.b.t("mry3rvmk-b952e5d").length===0||s.a!=null)return
|
|
150397
150397
|
s.Co()
|
|
150398
150398
|
s.a=A.mL(B.Yh,new A.bmK(s))},
|
|
150399
150399
|
Co(){var s=0,r=A.l(t.H),q,p=2,o=[],n=this,m,l,k,j,i,h,g,f
|
|
@@ -150411,7 +150411,7 @@ if(!t.f.b(k)){s=1
|
|
|
150411
150411
|
break}i=J.a0(k,"buildId")
|
|
150412
150412
|
h=i==null?null:B.b.t(J.q(i))
|
|
150413
150413
|
j=h==null?"":h
|
|
150414
|
-
if(J.bv(j)===0||J.e(j,"
|
|
150414
|
+
if(J.bv(j)===0||J.e(j,"mry3rvmk-b952e5d")){s=1
|
|
150415
150415
|
break}n.b=!0
|
|
150416
150416
|
n.H()
|
|
150417
150417
|
p=2
|
|
@@ -150428,7 +150428,7 @@ case 2:return A.i(o.at(-1),r)}})
|
|
|
150428
150428
|
return A.k($async$Co,r)},
|
|
150429
150429
|
wV(){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
|
|
150430
150430
|
var $async$wV=A.h(function(a2,a3){if(a2===1){o.push(a3)
|
|
150431
|
-
s=p}for(;;)switch(s){case 0:if(B.b.t("
|
|
150431
|
+
s=p}for(;;)switch(s){case 0:if(B.b.t("mry3rvmk-b952e5d").length===0||n.c){s=1
|
|
150432
150432
|
break}n.c=!0
|
|
150433
150433
|
n.H()
|
|
150434
150434
|
p=4
|
|
@@ -39,6 +39,9 @@ async function getBrowserHealth(userId, app, engine) {
|
|
|
39
39
|
const runtimeSettings = typeof runtimeManager?.getSettings === 'function'
|
|
40
40
|
? runtimeManager.getSettings(userId)
|
|
41
41
|
: null;
|
|
42
|
+
const runtimeSnapshot = typeof runtimeManager?.getCapabilitySnapshot === 'function'
|
|
43
|
+
? runtimeManager.getCapabilitySnapshot(userId)
|
|
44
|
+
: null;
|
|
42
45
|
const extensionStatus = runtimeSettings?.browser_backend === 'extension'
|
|
43
46
|
? app?.locals?.browserExtensionRegistry?.getStatus(userId)
|
|
44
47
|
: null;
|
|
@@ -63,122 +66,81 @@ async function getBrowserHealth(userId, app, engine) {
|
|
|
63
66
|
});
|
|
64
67
|
}
|
|
65
68
|
}
|
|
66
|
-
|
|
67
|
-
let resolutionError = null;
|
|
68
|
-
|
|
69
|
-
if (runtimeManager && typeof runtimeManager.getBrowserProviderForUser === 'function') {
|
|
70
|
-
try {
|
|
71
|
-
controller = await runtimeManager.getBrowserProviderForUser(userId);
|
|
72
|
-
} catch (err) {
|
|
73
|
-
resolutionError = err;
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
if (!controller) {
|
|
78
|
-
resolutionError = resolutionError || new Error('Browser provider is unavailable. VM runtime is required.');
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
if (!controller && resolutionError) {
|
|
69
|
+
if (!runtimeManager) {
|
|
82
70
|
return capabilityEntry({
|
|
83
|
-
|
|
84
|
-
healthy: false,
|
|
85
|
-
degraded: true,
|
|
86
|
-
summary: `Browser controller resolution failed: ${resolutionError.message}`,
|
|
87
|
-
details: {
|
|
88
|
-
error: resolutionError.message,
|
|
89
|
-
},
|
|
71
|
+
summary: 'Browser runtime is not available in this environment.',
|
|
90
72
|
});
|
|
91
73
|
}
|
|
92
|
-
let pageInfo = null;
|
|
93
|
-
let launched = false;
|
|
94
|
-
let error = null;
|
|
95
74
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
75
|
+
// Capability reporting runs on every agent turn. It must never resolve a
|
|
76
|
+
// provider because the VM provider performs image builds and container boot.
|
|
77
|
+
// Tool execution remains the authoritative health check and starts/restarts
|
|
78
|
+
// the runtime only when the model actually uses a browser tool.
|
|
79
|
+
const preferredBackend = runtimeSettings?.browser_backend
|
|
80
|
+
|| runtimeSnapshot?.browser?.preferredBackend
|
|
81
|
+
|| 'vm';
|
|
82
|
+
const vmInitialized = runtimeSnapshot?.browser?.vmInitialized === true;
|
|
83
|
+
const extensionConnected = extensionStatus?.connected === true
|
|
84
|
+
|| runtimeSnapshot?.browser?.extensionConnected === true;
|
|
85
|
+
const extensionPreferredButOffline = preferredBackend === 'extension' && !extensionConnected;
|
|
104
86
|
|
|
105
87
|
return capabilityEntry({
|
|
106
|
-
connected:
|
|
88
|
+
connected: vmInitialized || extensionConnected,
|
|
107
89
|
configured: true,
|
|
108
|
-
healthy:
|
|
109
|
-
degraded:
|
|
110
|
-
summary:
|
|
111
|
-
?
|
|
112
|
-
:
|
|
113
|
-
?
|
|
114
|
-
:
|
|
90
|
+
healthy: true,
|
|
91
|
+
degraded: extensionPreferredButOffline,
|
|
92
|
+
summary: extensionPreferredButOffline
|
|
93
|
+
? 'No extension device is active. The VM browser will start on first use.'
|
|
94
|
+
: vmInitialized
|
|
95
|
+
? 'Browser VM session is initialized.'
|
|
96
|
+
: 'Browser runtime is available and will start on first use.',
|
|
115
97
|
details: {
|
|
116
|
-
preferredBackend
|
|
117
|
-
backend:
|
|
118
|
-
|
|
119
|
-
: runtimeSettings?.browser_backend || null,
|
|
120
|
-
extensionConnected: extensionStatus?.connected === true,
|
|
98
|
+
preferredBackend,
|
|
99
|
+
backend: extensionConnected ? 'extension' : 'vm',
|
|
100
|
+
extensionConnected,
|
|
121
101
|
activeTokenCount: Array.isArray(extensionStatus?.tokens)
|
|
122
102
|
? extensionStatus.tokens.filter((token) => token.status === 'active').length
|
|
123
103
|
: 0,
|
|
124
|
-
|
|
125
|
-
pageInfo,
|
|
104
|
+
runtimeInitialized: vmInitialized,
|
|
126
105
|
},
|
|
127
106
|
});
|
|
128
107
|
}
|
|
129
108
|
|
|
130
109
|
async function getAndroidHealth(userId, app, engine) {
|
|
131
110
|
const runtimeManager = app?.locals?.runtimeManager || engine?.runtimeManager || null;
|
|
132
|
-
|
|
133
|
-
let resolutionError = null;
|
|
134
|
-
|
|
135
|
-
if (runtimeManager && typeof runtimeManager.getAndroidProviderForUser === 'function') {
|
|
136
|
-
try {
|
|
137
|
-
controller = await runtimeManager.getAndroidProviderForUser(userId);
|
|
138
|
-
} catch (err) {
|
|
139
|
-
resolutionError = err;
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
if (!controller) {
|
|
144
|
-
resolutionError = resolutionError || new Error('Android provider is unavailable. VM runtime is required.');
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
if (!controller || typeof controller.getStatus !== 'function') {
|
|
111
|
+
if (!runtimeManager) {
|
|
148
112
|
return capabilityEntry({
|
|
149
|
-
|
|
150
|
-
summary: resolutionError
|
|
151
|
-
? `Android controller resolution failed: ${resolutionError.message}`
|
|
152
|
-
: 'Android controller is not available.',
|
|
153
|
-
details: resolutionError ? { error: resolutionError.message } : {},
|
|
113
|
+
summary: 'Android runtime is not available in this environment.',
|
|
154
114
|
});
|
|
155
115
|
}
|
|
156
116
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
117
|
+
// Like browser health, Android health is a snapshot. Creating a controller or
|
|
118
|
+
// running adb here makes unrelated chat turns pay runtime startup/status costs.
|
|
119
|
+
const runtimeSnapshot = typeof runtimeManager.getCapabilitySnapshot === 'function'
|
|
120
|
+
? runtimeManager.getCapabilitySnapshot(userId)
|
|
121
|
+
: null;
|
|
122
|
+
const status = runtimeSnapshot?.android?.status || null;
|
|
123
|
+
const bootstrapped = status?.bootstrapped === true;
|
|
124
|
+
const starting = status?.starting === true;
|
|
125
|
+
const lastStartError = String(status?.lastStartError || '').trim();
|
|
126
|
+
return capabilityEntry({
|
|
127
|
+
connected: bootstrapped,
|
|
128
|
+
configured: true,
|
|
129
|
+
healthy: !lastStartError,
|
|
130
|
+
degraded: Boolean(lastStartError),
|
|
131
|
+
summary: lastStartError
|
|
132
|
+
? `Android tooling reported: ${lastStartError}`
|
|
133
|
+
: bootstrapped
|
|
134
|
+
? 'Android environment is ready.'
|
|
135
|
+
: starting
|
|
136
|
+
? 'Android environment is starting.'
|
|
137
|
+
: 'Android runtime is available and will initialize on first use.',
|
|
138
|
+
details: status || {
|
|
139
|
+
initialized: runtimeSnapshot?.android?.initialized === true,
|
|
140
|
+
bootstrapped: false,
|
|
141
|
+
starting: false,
|
|
142
|
+
},
|
|
143
|
+
});
|
|
182
144
|
}
|
|
183
145
|
|
|
184
146
|
function getMessagingHealth(userId, app, engine, agentId = null) {
|
|
@@ -342,7 +304,9 @@ function getTaskHealth(userId, agentId = null) {
|
|
|
342
304
|
}
|
|
343
305
|
|
|
344
306
|
async function getCapabilityHealth({ userId, agentId = null, app, engine }) {
|
|
345
|
-
const providers = await getProviderHealthCatalog(userId, agentId
|
|
307
|
+
const providers = await getProviderHealthCatalog(userId, agentId, {
|
|
308
|
+
probeLocal: false,
|
|
309
|
+
});
|
|
346
310
|
|
|
347
311
|
return {
|
|
348
312
|
providers,
|
|
@@ -363,6 +327,8 @@ async function getCapabilityHealth({ userId, agentId = null, app, engine }) {
|
|
|
363
327
|
}
|
|
364
328
|
|
|
365
329
|
module.exports = {
|
|
330
|
+
getAndroidHealth,
|
|
331
|
+
getBrowserHealth,
|
|
366
332
|
getCapabilityHealth,
|
|
367
333
|
summarizeCapabilityHealth,
|
|
368
334
|
};
|
|
@@ -59,6 +59,11 @@ const {
|
|
|
59
59
|
normalizeModelSelections,
|
|
60
60
|
resolveModelSelection,
|
|
61
61
|
} = require('../model_identity');
|
|
62
|
+
const {
|
|
63
|
+
isModelCoolingDown,
|
|
64
|
+
recordModelFailure,
|
|
65
|
+
recordModelSuccess,
|
|
66
|
+
} = require('../model_failure_cache');
|
|
62
67
|
const { getProviderForUser } = require('../provider_selector');
|
|
63
68
|
const { IterationBudget } = require('./iteration_budget');
|
|
64
69
|
const {
|
|
@@ -421,7 +426,10 @@ async function getFailureFallbackModelId(
|
|
|
421
426
|
const { getSupportedModels } = require('../models');
|
|
422
427
|
const aiSettings = getAiSettings(userId, agentId);
|
|
423
428
|
const models = await getSupportedModels(userId, agentId, { signal });
|
|
424
|
-
const availableModels = models.filter(
|
|
429
|
+
const availableModels = models.filter(
|
|
430
|
+
(model) => model.available !== false
|
|
431
|
+
&& !isModelCoolingDown(userId, agentId, model.id),
|
|
432
|
+
);
|
|
425
433
|
const configuredEnabledIds = Array.isArray(aiSettings.enabled_models)
|
|
426
434
|
? aiSettings.enabled_models.map((id) => String(id).trim()).filter(Boolean)
|
|
427
435
|
: [];
|
|
@@ -599,6 +607,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
|
|
|
599
607
|
db.prepare('UPDATE agent_runs SET model = ?, updated_at = datetime(\'now\') WHERE id = ?')
|
|
600
608
|
.run(modelSelectionId, runId);
|
|
601
609
|
const switchToFallbackModel = async (failedSelectionId, error, phase) => {
|
|
610
|
+
recordModelFailure(userId, agentId, failedSelectionId, error);
|
|
602
611
|
const fallbackModelId = await getFailureFallbackModelId(
|
|
603
612
|
userId,
|
|
604
613
|
agentId,
|
|
@@ -639,12 +648,16 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
|
|
|
639
648
|
};
|
|
640
649
|
const runWithModelFallback = async (phase, fn) => {
|
|
641
650
|
try {
|
|
642
|
-
|
|
651
|
+
const result = await fn();
|
|
652
|
+
recordModelSuccess(userId, agentId, modelSelectionId);
|
|
653
|
+
return result;
|
|
643
654
|
} catch (err) {
|
|
644
655
|
const failedSelectionId = modelSelectionId;
|
|
645
656
|
const switched = await switchToFallbackModel(failedSelectionId, err, phase);
|
|
646
657
|
if (!switched) throw err;
|
|
647
|
-
|
|
658
|
+
const result = await fn();
|
|
659
|
+
recordModelSuccess(userId, agentId, modelSelectionId);
|
|
660
|
+
return result;
|
|
648
661
|
}
|
|
649
662
|
};
|
|
650
663
|
|
|
@@ -1492,8 +1505,10 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
|
|
|
1492
1505
|
response = modelCall.response;
|
|
1493
1506
|
responseModel = modelCall.responseModel;
|
|
1494
1507
|
streamContent = modelCall.streamContent;
|
|
1508
|
+
recordModelSuccess(userId, agentId, modelSelectionId);
|
|
1495
1509
|
} catch (err) {
|
|
1496
1510
|
console.error(`[Engine] Model call failed (${model}):`, err.message);
|
|
1511
|
+
recordModelFailure(userId, agentId, modelSelectionId, err);
|
|
1497
1512
|
const fallbackModelId = retryForFallback
|
|
1498
1513
|
? await getFailureFallbackModelId(
|
|
1499
1514
|
userId,
|
|
@@ -1561,6 +1576,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
|
|
|
1561
1576
|
response = fallbackCall.response;
|
|
1562
1577
|
responseModel = fallbackCall.responseModel;
|
|
1563
1578
|
streamContent = fallbackCall.streamContent;
|
|
1579
|
+
recordModelSuccess(userId, agentId, modelSelectionId);
|
|
1564
1580
|
} else {
|
|
1565
1581
|
throw err;
|
|
1566
1582
|
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_COOLDOWN_MS = 15 * 60 * 1000;
|
|
4
|
+
const failures = new Map();
|
|
5
|
+
|
|
6
|
+
function cacheKey(userId, agentId, modelSelectionId) {
|
|
7
|
+
return [
|
|
8
|
+
String(userId ?? ''),
|
|
9
|
+
String(agentId ?? 'main'),
|
|
10
|
+
String(modelSelectionId || '').trim(),
|
|
11
|
+
].join(':');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function readCooldownMs() {
|
|
15
|
+
const configured = Number(process.env.NEOAGENT_MODEL_NOT_FOUND_COOLDOWN_MS);
|
|
16
|
+
if (!Number.isFinite(configured)) return DEFAULT_COOLDOWN_MS;
|
|
17
|
+
return Math.max(1_000, Math.min(configured, 24 * 60 * 60 * 1000));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isPermanentModelFailure(error) {
|
|
21
|
+
const status = Number(
|
|
22
|
+
error?.status
|
|
23
|
+
?? error?.statusCode
|
|
24
|
+
?? error?.response?.status,
|
|
25
|
+
);
|
|
26
|
+
if (status === 404) return true;
|
|
27
|
+
return /\b404\b.*\b(model|nim|provider|request)\b|\b(model|nim)\b.*\b404\b/i
|
|
28
|
+
.test(String(error?.message || ''));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function recordModelFailure(userId, agentId, modelSelectionId, error, now = Date.now()) {
|
|
32
|
+
const modelId = String(modelSelectionId || '').trim();
|
|
33
|
+
if (!modelId || !isPermanentModelFailure(error)) return false;
|
|
34
|
+
failures.set(cacheKey(userId, agentId, modelId), {
|
|
35
|
+
expiresAt: now + readCooldownMs(),
|
|
36
|
+
});
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function recordModelSuccess(userId, agentId, modelSelectionId) {
|
|
41
|
+
const modelId = String(modelSelectionId || '').trim();
|
|
42
|
+
if (!modelId) return false;
|
|
43
|
+
return failures.delete(cacheKey(userId, agentId, modelId));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isModelCoolingDown(userId, agentId, modelSelectionId, now = Date.now()) {
|
|
47
|
+
const modelId = String(modelSelectionId || '').trim();
|
|
48
|
+
if (!modelId) return false;
|
|
49
|
+
const key = cacheKey(userId, agentId, modelId);
|
|
50
|
+
const entry = failures.get(key);
|
|
51
|
+
if (!entry) return false;
|
|
52
|
+
if (entry.expiresAt <= now) {
|
|
53
|
+
failures.delete(key);
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function clearModelFailureCache() {
|
|
60
|
+
failures.clear();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = {
|
|
64
|
+
clearModelFailureCache,
|
|
65
|
+
isModelCoolingDown,
|
|
66
|
+
isPermanentModelFailure,
|
|
67
|
+
recordModelFailure,
|
|
68
|
+
recordModelSuccess,
|
|
69
|
+
};
|
|
@@ -264,7 +264,7 @@ async function getProviderHealthCatalog(userId, agentId = null, options = {}) {
|
|
|
264
264
|
let statusLabel = provider.statusLabel;
|
|
265
265
|
let availabilityReason = provider.availabilityReason;
|
|
266
266
|
|
|
267
|
-
if (provider.id === 'ollama' && provider.enabled) {
|
|
267
|
+
if (provider.id === 'ollama' && provider.enabled && options.probeLocal !== false) {
|
|
268
268
|
const probe = await probeOllama(
|
|
269
269
|
provider.baseUrl || AI_PROVIDER_DEFINITIONS.ollama.defaultBaseUrl,
|
|
270
270
|
1500,
|
|
@@ -7,6 +7,7 @@ const {
|
|
|
7
7
|
normalizeModelSelections,
|
|
8
8
|
resolveModelSelection,
|
|
9
9
|
} = require('./model_identity');
|
|
10
|
+
const { isModelCoolingDown } = require('./model_failure_cache');
|
|
10
11
|
|
|
11
12
|
function buildSelection(model, userId, providerConfig) {
|
|
12
13
|
return {
|
|
@@ -30,12 +31,29 @@ async function getProviderForUser(
|
|
|
30
31
|
signal: providerConfig.signal,
|
|
31
32
|
});
|
|
32
33
|
const selectableModels = models.filter((model) => model.available !== false);
|
|
34
|
+
const readyModels = selectableModels.filter(
|
|
35
|
+
(model) => !isModelCoolingDown(userId, agentId, model.id),
|
|
36
|
+
);
|
|
33
37
|
|
|
34
38
|
if (modelOverride && typeof modelOverride === 'string') {
|
|
35
39
|
const requested = resolveModelSelection(selectableModels, modelOverride);
|
|
36
40
|
if (!requested) {
|
|
37
41
|
throw new Error(`Requested model '${modelOverride.trim()}' is not available.`);
|
|
38
42
|
}
|
|
43
|
+
if (isModelCoolingDown(userId, agentId, requested.id) && readyModels.length > 0) {
|
|
44
|
+
const configuredFallback = resolveModelSelection(
|
|
45
|
+
readyModels,
|
|
46
|
+
aiSettings.fallback_model_id,
|
|
47
|
+
);
|
|
48
|
+
const fallback = configuredFallback
|
|
49
|
+
|| readyModels.find((model) => model.provider !== requested.provider)
|
|
50
|
+
|| readyModels[0];
|
|
51
|
+
providerConfig.onStatus?.({
|
|
52
|
+
phase: 'model_fallback',
|
|
53
|
+
message: `Skipping recently unavailable model ${requested.id}; using ${fallback.id}.`,
|
|
54
|
+
});
|
|
55
|
+
return buildSelection(fallback, userId, providerConfig);
|
|
56
|
+
}
|
|
39
57
|
return buildSelection(requested, userId, providerConfig);
|
|
40
58
|
}
|
|
41
59
|
|
|
@@ -43,9 +61,35 @@ async function getProviderForUser(
|
|
|
43
61
|
? aiSettings.enabled_models.map((id) => String(id).trim()).filter(Boolean)
|
|
44
62
|
: [];
|
|
45
63
|
const enabledIds = normalizeModelSelections(selectableModels, configuredEnabledIds);
|
|
46
|
-
|
|
47
|
-
?
|
|
48
|
-
:
|
|
64
|
+
let availableModels = configuredEnabledIds.length > 0
|
|
65
|
+
? readyModels.filter((model) => enabledIds.includes(model.id))
|
|
66
|
+
: readyModels;
|
|
67
|
+
|
|
68
|
+
if (
|
|
69
|
+
availableModels.length === 0
|
|
70
|
+
&& configuredEnabledIds.length > 0
|
|
71
|
+
&& enabledIds.length > 0
|
|
72
|
+
) {
|
|
73
|
+
const configuredFallback = resolveModelSelection(
|
|
74
|
+
readyModels,
|
|
75
|
+
aiSettings.fallback_model_id,
|
|
76
|
+
);
|
|
77
|
+
if (configuredFallback) {
|
|
78
|
+
providerConfig.onStatus?.({
|
|
79
|
+
phase: 'model_fallback',
|
|
80
|
+
message: `Enabled models are temporarily unavailable; using ${configuredFallback.id}.`,
|
|
81
|
+
});
|
|
82
|
+
availableModels = [configuredFallback];
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// If every configured model is cooling down and there is no alternative,
|
|
87
|
+
// allow one retry instead of making the installation permanently unavailable.
|
|
88
|
+
if (availableModels.length === 0 && readyModels.length === 0) {
|
|
89
|
+
availableModels = configuredEnabledIds.length > 0
|
|
90
|
+
? selectableModels.filter((model) => enabledIds.includes(model.id))
|
|
91
|
+
: selectableModels;
|
|
92
|
+
}
|
|
49
93
|
|
|
50
94
|
if (availableModels.length === 0) {
|
|
51
95
|
const message = configuredEnabledIds.length > 0
|
|
@@ -64,6 +108,17 @@ async function getProviderForUser(
|
|
|
64
108
|
let selectedModel = fallbackModel;
|
|
65
109
|
if (userSelectedDefault !== 'auto') {
|
|
66
110
|
selectedModel = resolveModelSelection(availableModels, userSelectedDefault) || fallbackModel;
|
|
111
|
+
const configuredDefault = resolveModelSelection(selectableModels, userSelectedDefault);
|
|
112
|
+
if (
|
|
113
|
+
configuredDefault
|
|
114
|
+
&& configuredDefault.id !== selectedModel.id
|
|
115
|
+
&& isModelCoolingDown(userId, agentId, configuredDefault.id)
|
|
116
|
+
) {
|
|
117
|
+
providerConfig.onStatus?.({
|
|
118
|
+
phase: 'model_fallback',
|
|
119
|
+
message: `Skipping recently unavailable model ${configuredDefault.id}; using ${selectedModel.id}.`,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
67
122
|
} else {
|
|
68
123
|
const selectionHint = providerConfig.selectionHint && typeof providerConfig.selectionHint === 'object'
|
|
69
124
|
? providerConfig.selectionHint
|
|
@@ -94,7 +94,9 @@ class GoogleProvider extends BaseProvider {
|
|
|
94
94
|
}
|
|
95
95
|
if (msg.role === 'tool') {
|
|
96
96
|
history.push({
|
|
97
|
-
|
|
97
|
+
// Gemini chat history only accepts user/model roles. Tool results are
|
|
98
|
+
// represented as functionResponse parts on a user turn.
|
|
99
|
+
role: 'user',
|
|
98
100
|
parts: [{
|
|
99
101
|
functionResponse: {
|
|
100
102
|
name: msg.name || 'tool',
|
|
@@ -147,6 +149,18 @@ class GoogleProvider extends BaseProvider {
|
|
|
147
149
|
normalizedHistory.push({ role: currentRole, parts: currentParts });
|
|
148
150
|
}
|
|
149
151
|
|
|
152
|
+
// Structured helpers can consist entirely of system instructions. Gemini's
|
|
153
|
+
// chat API still requires a user turn to trigger generation.
|
|
154
|
+
if (
|
|
155
|
+
normalizedHistory.length === 0
|
|
156
|
+
|| normalizedHistory[normalizedHistory.length - 1].role === 'model'
|
|
157
|
+
) {
|
|
158
|
+
normalizedHistory.push({
|
|
159
|
+
role: 'user',
|
|
160
|
+
parts: [{ text: 'Provide the requested response using the system instructions.' }]
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
150
164
|
if (normalizedHistory.length > 0 && normalizedHistory[0].role !== 'user') {
|
|
151
165
|
normalizedHistory.unshift({
|
|
152
166
|
role: 'user',
|
|
@@ -306,6 +306,15 @@ class DockerVMManager {
|
|
|
306
306
|
return Boolean(session && isContainerRunning(session.containerId));
|
|
307
307
|
}
|
|
308
308
|
|
|
309
|
+
// A side-effect-free snapshot for request-path capability reporting. Unlike
|
|
310
|
+
// hasVm(), this deliberately avoids a synchronous `docker inspect`, which can
|
|
311
|
+
// block the Node event loop. Tool execution still calls ensureVm() and performs
|
|
312
|
+
// the authoritative liveness check before using a tracked session.
|
|
313
|
+
hasTrackedVm(userId) {
|
|
314
|
+
const key = String(userId || '').trim();
|
|
315
|
+
return Boolean(key && this.instances.has(key));
|
|
316
|
+
}
|
|
317
|
+
|
|
309
318
|
// Used by validation.js — cached to avoid docker calls on every status poll.
|
|
310
319
|
getReadiness() {
|
|
311
320
|
const now = Date.now();
|
|
@@ -13,6 +13,8 @@ const GUEST_PAYLOAD_PROFILES = Object.freeze({
|
|
|
13
13
|
{ source: 'runtime/paths.js', target: 'runtime/paths.js' },
|
|
14
14
|
{ source: 'server/guest_agent.js', target: 'server/guest_agent.js' },
|
|
15
15
|
{ source: 'server/services/browser', target: 'server/services/browser' },
|
|
16
|
+
{ source: 'server/services/android/process.js', target: 'server/services/android/process.js' },
|
|
17
|
+
{ source: 'server/utils/cloud-security.js', target: 'server/utils/cloud-security.js' },
|
|
16
18
|
],
|
|
17
19
|
cli: [
|
|
18
20
|
{ source: 'server/guest-agent.cli.package.json', target: 'package.json' },
|
|
@@ -28,6 +30,8 @@ const GUEST_PAYLOAD_PROFILES = Object.freeze({
|
|
|
28
30
|
{ source: 'server/guest_agent.js', target: 'server/guest_agent.js' },
|
|
29
31
|
{ source: 'server/services/cli', target: 'server/services/cli' },
|
|
30
32
|
{ source: 'server/services/browser', target: 'server/services/browser' },
|
|
33
|
+
{ source: 'server/services/android/process.js', target: 'server/services/android/process.js' },
|
|
34
|
+
{ source: 'server/utils/cloud-security.js', target: 'server/utils/cloud-security.js' },
|
|
31
35
|
],
|
|
32
36
|
android: [
|
|
33
37
|
{ source: 'server/guest-agent.android.package.json', target: 'package.json' },
|
|
@@ -36,6 +40,9 @@ const GUEST_PAYLOAD_PROFILES = Object.freeze({
|
|
|
36
40
|
{ source: 'server/guest_agent.js', target: 'server/guest_agent.js' },
|
|
37
41
|
{ source: 'server/services/cli', target: 'server/services/cli' },
|
|
38
42
|
{ source: 'server/services/android', target: 'server/services/android' },
|
|
43
|
+
{ source: 'server/utils/abort.js', target: 'server/utils/abort.js' },
|
|
44
|
+
{ source: 'server/utils/cloud-security.js', target: 'server/utils/cloud-security.js' },
|
|
45
|
+
{ source: 'server/utils/image_payload.js', target: 'server/utils/image_payload.js' },
|
|
39
46
|
],
|
|
40
47
|
});
|
|
41
48
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const crypto = require('crypto');
|
|
6
|
-
const { spawnSync } = require('child_process');
|
|
6
|
+
const { spawn, spawnSync } = require('child_process');
|
|
7
7
|
const { DATA_DIR } = require('../../../runtime/paths');
|
|
8
8
|
const { stageGuestPayload, normalizeRuntimeProfile } = require('./guest_bootstrap');
|
|
9
9
|
|
|
@@ -105,6 +105,34 @@ function dockerAvailable() {
|
|
|
105
105
|
return !result.error && result.status === 0;
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
function runDockerCommand(args, options = {}) {
|
|
109
|
+
const timeoutMs = Number(options.timeout || BUILD_TIMEOUT_MS);
|
|
110
|
+
const spawnImpl = options.spawnImpl || spawn;
|
|
111
|
+
return new Promise((resolve, reject) => {
|
|
112
|
+
const child = spawnImpl('docker', args, {
|
|
113
|
+
stdio: options.stdio || ['ignore', 'inherit', 'inherit'],
|
|
114
|
+
windowsHide: true,
|
|
115
|
+
});
|
|
116
|
+
let settled = false;
|
|
117
|
+
const finish = (error, status = null) => {
|
|
118
|
+
if (settled) return;
|
|
119
|
+
settled = true;
|
|
120
|
+
clearTimeout(timeout);
|
|
121
|
+
if (error) reject(error);
|
|
122
|
+
else resolve(status);
|
|
123
|
+
};
|
|
124
|
+
const timeout = setTimeout(() => {
|
|
125
|
+
const error = new Error(`docker ${args[0]} timed out after ${timeoutMs}ms`);
|
|
126
|
+
error.code = 'DOCKER_COMMAND_TIMEOUT';
|
|
127
|
+
try { child.kill('SIGKILL'); } catch {}
|
|
128
|
+
finish(error);
|
|
129
|
+
}, timeoutMs);
|
|
130
|
+
timeout.unref?.();
|
|
131
|
+
child.once('error', (error) => finish(error));
|
|
132
|
+
child.once('close', (status) => finish(null, status));
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
108
136
|
// Builds and caches the per-profile guest-agent Docker image. The image bakes in
|
|
109
137
|
// the guest agent, its dependencies, and (for browser_cli) the Chromium browser,
|
|
110
138
|
// so containers start the agent directly with no runtime installation step.
|
|
@@ -166,15 +194,16 @@ class GuestImageBuilder {
|
|
|
166
194
|
async #build(tag) {
|
|
167
195
|
console.log(`[GuestImage:${this.profile}] Building guest image ${tag} (one-time; downloads browser + deps)…`);
|
|
168
196
|
const started = Date.now();
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
197
|
+
let status;
|
|
198
|
+
try {
|
|
199
|
+
status = await runDockerCommand(['build', '-t', tag, this.contextDir], {
|
|
200
|
+
timeout: BUILD_TIMEOUT_MS,
|
|
201
|
+
});
|
|
202
|
+
} catch (error) {
|
|
203
|
+
throw new Error(`Guest image build failed: ${error.message}`, { cause: error });
|
|
175
204
|
}
|
|
176
|
-
if (
|
|
177
|
-
throw new Error(`Guest image build for ${tag} exited with status ${
|
|
205
|
+
if (status !== 0) {
|
|
206
|
+
throw new Error(`Guest image build for ${tag} exited with status ${status}`);
|
|
178
207
|
}
|
|
179
208
|
this.#cachedBuiltAt = Date.now();
|
|
180
209
|
console.log(`[GuestImage:${this.profile}] Built ${tag} in ${Math.round((Date.now() - started) / 1000)}s`);
|
|
@@ -185,5 +214,6 @@ class GuestImageBuilder {
|
|
|
185
214
|
module.exports = {
|
|
186
215
|
GuestImageBuilder,
|
|
187
216
|
dockerAvailable,
|
|
217
|
+
runDockerCommand,
|
|
188
218
|
BASE_IMAGE,
|
|
189
219
|
};
|
|
@@ -87,6 +87,41 @@ class RuntimeManager {
|
|
|
87
87
|
);
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
getCapabilitySnapshot(userId) {
|
|
91
|
+
const settings = this.getSettings(userId) || {};
|
|
92
|
+
const key = String(userId || '').trim();
|
|
93
|
+
const extensionConnected = this.hasActiveExtensionBrowser(userId);
|
|
94
|
+
const androidController = key && this.androidControllers instanceof Map
|
|
95
|
+
? this.androidControllers.get(key)
|
|
96
|
+
: null;
|
|
97
|
+
let androidStatus = null;
|
|
98
|
+
if (androidController && typeof androidController.getStatusSync === 'function') {
|
|
99
|
+
try {
|
|
100
|
+
androidStatus = androidController.getStatusSync();
|
|
101
|
+
} catch {
|
|
102
|
+
androidStatus = null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
browser: {
|
|
108
|
+
preferredBackend: settings.browser_backend,
|
|
109
|
+
activeBackend: extensionConnected ? 'extension' : 'vm',
|
|
110
|
+
extensionConnected,
|
|
111
|
+
vmInitialized: Boolean(
|
|
112
|
+
this.browserBackend?.vmManager?.hasTrackedVm?.(userId),
|
|
113
|
+
),
|
|
114
|
+
},
|
|
115
|
+
android: {
|
|
116
|
+
initialized: Boolean(androidController),
|
|
117
|
+
status: androidStatus,
|
|
118
|
+
},
|
|
119
|
+
desktop: {
|
|
120
|
+
connected: this.hasActiveDesktopCompanion(userId),
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
90
125
|
resolveBackend(userId, requested) {
|
|
91
126
|
void userId;
|
|
92
127
|
return requested === 'browser' ? this.browserBackend : this.cliBackend;
|