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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neoagent",
3
- "version": "3.2.1-beta.6",
3
+ "version": "3.2.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",
@@ -1 +1 @@
1
- 47dfb68683e209ba52dcaade6052a7c2
1
+ 4e3d49d31cbb561d89adf60e16759ebe
@@ -37,6 +37,6 @@ _flutter.buildConfig = {"engineRevision":"69c8c61792f04cc809dfef0c910414fb9afc06
37
37
 
38
38
  _flutter.loader.load({
39
39
  serviceWorkerSettings: {
40
- serviceWorkerVersion: "1886361738" /* Flutter's service worker is deprecated and will be removed in a future Flutter release. */
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("mry17udu-e9492af").length!==0&&r.b}else r=!1
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("mry17udu-e9492af").length===0||s.a!=null)return
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,"mry17udu-e9492af")){s=1
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("mry17udu-e9492af").length===0||n.c){s=1
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
- let controller = null;
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
- configured: true,
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
- try {
97
- launched = typeof controller?.isLaunched === 'function'
98
- ? await Promise.resolve(controller.isLaunched())
99
- : false;
100
- pageInfo = typeof controller?.getPageInfo === 'function' ? await controller.getPageInfo() : null;
101
- } catch (err) {
102
- error = err.message;
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: launched,
88
+ connected: vmInitialized || extensionConnected,
107
89
  configured: true,
108
- healthy: !error,
109
- degraded: Boolean(error) || runtimeSettings?.browser_backend === 'extension',
110
- summary: error
111
- ? `Browser runtime error: ${error}`
112
- : runtimeSettings?.browser_backend === 'extension'
113
- ? `No extension device is active. Falling back to the vm browser runtime.`
114
- : (launched ? 'Browser runtime is ready.' : 'Browser runtime is available but not launched.'),
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: runtimeSettings?.browser_backend || null,
117
- backend: runtimeSettings?.browser_backend === 'extension'
118
- ? 'vm'
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
- launched,
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
- let controller = null;
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
- degraded: Boolean(resolutionError),
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
- try {
158
- const status = await controller.getStatus();
159
- const bootstrapped = status.bootstrapped === true;
160
- const canBootstrap = status.canBootstrap === true;
161
- return capabilityEntry({
162
- connected: Array.isArray(status.devices) && status.devices.some((device) => device.status === 'device'),
163
- configured: canBootstrap || bootstrapped,
164
- healthy: canBootstrap || bootstrapped,
165
- degraded: Boolean(status.lastStartError),
166
- summary: status.lastStartError
167
- ? `Android tooling reported: ${status.lastStartError}`
168
- : bootstrapped
169
- ? 'Android environment is ready on this host.'
170
- : (canBootstrap ? 'Android environment can be bootstrapped on this host.' : 'Android tooling cannot bootstrap on this host.'),
171
- details: status,
172
- });
173
- } catch (err) {
174
- return capabilityEntry({
175
- configured: true,
176
- healthy: false,
177
- degraded: true,
178
- summary: `Android status check failed: ${err.message}`,
179
- details: { error: err.message },
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((model) => model.available !== false);
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
- return await fn();
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
- return await fn();
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
- const availableModels = configuredEnabledIds.length > 0
47
- ? selectableModels.filter((model) => enabledIds.includes(model.id))
48
- : selectableModels;
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
- role: 'function',
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
- const result = docker(['build', '-t', tag, this.contextDir], {
170
- timeout: BUILD_TIMEOUT_MS,
171
- stdio: ['ignore', 'inherit', 'inherit'],
172
- });
173
- if (result.error) {
174
- throw new Error(`Guest image build failed to start: ${result.error.message}`);
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 (result.status !== 0) {
177
- throw new Error(`Guest image build for ${tag} exited with status ${result.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;