neoagent 2.1.13 → 2.1.15

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.
@@ -11,6 +11,7 @@ router.use(requireAuth);
11
11
 
12
12
  const androidApkUploadDir = path.join(DATA_DIR, 'uploads', 'android-apks');
13
13
  fs.mkdirSync(androidApkUploadDir, { recursive: true });
14
+ const INSTALLABLE_ANDROID_PACKAGE_EXTENSIONS = new Set(['.apk', '.apks']);
14
15
 
15
16
  const androidApkUpload = multer({
16
17
  storage: multer.diskStorage({
@@ -28,8 +29,9 @@ const androidApkUpload = multer({
28
29
  },
29
30
  }),
30
31
  fileFilter: (_req, file, cb) => {
31
- if (!String(file.originalname || '').toLowerCase().endsWith('.apk')) {
32
- cb(new Error('Only .apk files can be installed.'));
32
+ const extension = path.extname(String(file.originalname || '')).toLowerCase();
33
+ if (!INSTALLABLE_ANDROID_PACKAGE_EXTENSIONS.has(extension)) {
34
+ cb(new Error('Only .apk or .apks files can be installed.'));
33
35
  return;
34
36
  }
35
37
  cb(null, true);
@@ -192,7 +194,7 @@ router.post('/install-apk', (req, res) => {
192
194
  const message =
193
195
  uploadError instanceof multer.MulterError &&
194
196
  uploadError.code === 'LIMIT_FILE_SIZE'
195
- ? 'APK upload is too large. Limit is 512MB.'
197
+ ? 'Android app upload is too large. Limit is 512MB.'
196
198
  : sanitizeError(uploadError);
197
199
  res.status(400).json({ error: message });
198
200
  return;
@@ -200,7 +202,7 @@ router.post('/install-apk', (req, res) => {
200
202
 
201
203
  const uploadedApkPath = req.file?.path;
202
204
  if (!uploadedApkPath) {
203
- res.status(400).json({ error: 'No APK file was uploaded.' });
205
+ res.status(400).json({ error: 'No APK or APK bundle was uploaded.' });
204
206
  return;
205
207
  }
206
208
 
@@ -7,6 +7,12 @@ const { requireAuth } = require('../middleware/auth');
7
7
  const { normalizeWhatsAppWhitelist } = require('../utils/whatsapp');
8
8
  const { getVersionInfo } = require('../utils/version');
9
9
  const { UPDATE_STATUS_FILE, APP_DIR } = require('../../runtime/paths');
10
+ const {
11
+ parseReleaseChannel,
12
+ getReleaseChannelBranch,
13
+ getReleaseChannelDistTag,
14
+ writeReleaseChannelToEnvFile,
15
+ } = require('../../runtime/release_channel');
10
16
  const {
11
17
  createDefaultAiSettings,
12
18
  ensureDefaultAiSettings,
@@ -316,6 +322,27 @@ router.post('/update', (req, res) => {
316
322
  res.json({ success: true, message: 'Update triggered', pid: child.pid });
317
323
  });
318
324
 
325
+ router.put('/update/channel', (req, res) => {
326
+ const requested = req.body?.channel;
327
+ const releaseChannel = parseReleaseChannel(requested);
328
+ if (!releaseChannel) {
329
+ return res.status(400).json({
330
+ success: false,
331
+ error: 'Release channel must be "stable" or "beta".',
332
+ });
333
+ }
334
+
335
+ writeReleaseChannelToEnvFile(releaseChannel);
336
+ process.env.NEOAGENT_RELEASE_CHANNEL = releaseChannel;
337
+
338
+ res.json({
339
+ success: true,
340
+ releaseChannel,
341
+ targetBranch: getReleaseChannelBranch(releaseChannel),
342
+ npmDistTag: getReleaseChannelDistTag(releaseChannel),
343
+ });
344
+ });
345
+
319
346
  router.get('/update/status', (req, res) => {
320
347
  const status = readUpdateStatus();
321
348
  const version = getVersionInfo();
@@ -325,7 +352,11 @@ router.get('/update/status', (req, res) => {
325
352
  installedVersion: version.installedVersion,
326
353
  packageVersion: version.packageVersion,
327
354
  gitVersion: version.gitVersion,
328
- gitSha: version.gitSha
355
+ gitSha: version.gitSha,
356
+ gitBranch: version.gitBranch,
357
+ releaseChannel: version.releaseChannel,
358
+ targetBranch: version.targetBranch,
359
+ npmDistTag: version.npmDistTag,
329
360
  });
330
361
  });
331
362
 
@@ -2,7 +2,12 @@ const { v4: uuidv4 } = require('uuid');
2
2
  const fs = require('fs');
3
3
  const db = require('../../db/database');
4
4
  const { compact } = require('./compaction');
5
- const { getConversationContext, buildSummaryCarrier, refreshConversationSummary } = require('./history');
5
+ const {
6
+ getConversationContext,
7
+ buildSummaryCarrier,
8
+ refreshConversationSummary,
9
+ sanitizeConversationMessages
10
+ } = require('./history');
6
11
  const { ensureDefaultAiSettings, getAiSettings } = require('./settings');
7
12
  const { selectToolsForTask } = require('./toolSelector');
8
13
  const { compactToolResult } = require('./toolResult');
@@ -438,6 +443,7 @@ class AgentEngine {
438
443
 
439
444
  let messages = this.buildContextMessages(systemPrompt, summaryMessage, historyMessages, recallMsg);
440
445
  messages.push(this.buildUserMessage(userMessage, options));
446
+ messages = sanitizeConversationMessages(messages);
441
447
 
442
448
  if (conversationId) {
443
449
  db.prepare('INSERT INTO conversation_messages (conversation_id, role, content) VALUES (?, ?, ?)')
@@ -461,11 +467,13 @@ class AgentEngine {
461
467
  conversationId
462
468
  });
463
469
  messages = steeringAtLoopStart.messages;
470
+ messages = sanitizeConversationMessages(messages);
464
471
 
465
472
  let metrics = this.estimatePromptMetrics(messages, tools);
466
473
  const contextWindow = provider.getContextWindow(model);
467
474
  if (metrics.totalEstimatedTokens > contextWindow * 0.7) {
468
475
  messages = await compact(messages, provider, model);
476
+ messages = sanitizeConversationMessages(messages);
469
477
  this.emit(userId, 'run:compaction', { runId, iteration });
470
478
  metrics = this.estimatePromptMetrics(messages, tools);
471
479
  }
@@ -480,9 +488,10 @@ class AgentEngine {
480
488
  const callOptions = { model, reasoningEffort: this.getReasoningEffort(providerName, options) };
481
489
 
482
490
  const tryModelCall = async (retryForFallback = true) => {
491
+ const requestMessages = sanitizeConversationMessages(messages);
483
492
  try {
484
493
  if (options.stream !== false) {
485
- const gen = provider.stream(messages, tools, callOptions);
494
+ const gen = provider.stream(requestMessages, tools, callOptions);
486
495
  for await (const chunk of gen) {
487
496
  if (chunk.type === 'content') {
488
497
  streamContent += chunk.content;
@@ -508,7 +517,7 @@ class AgentEngine {
508
517
  }
509
518
  }
510
519
  } else {
511
- response = await provider.chat(messages, tools, callOptions);
520
+ response = await provider.chat(requestMessages, tools, callOptions);
512
521
  responseModel = model;
513
522
  }
514
523
  } catch (err) {
@@ -528,9 +537,10 @@ class AgentEngine {
528
537
 
529
538
  // Recursive call once
530
539
  const retryOptions = { ...callOptions, model, reasoningEffort: this.getReasoningEffort(providerName, options) };
540
+ const retryMessages = sanitizeConversationMessages(messages);
531
541
 
532
542
  if (options.stream !== false) {
533
- const gen = provider.stream(messages, tools, retryOptions);
543
+ const gen = provider.stream(retryMessages, tools, retryOptions);
534
544
  for await (const chunk of gen) {
535
545
  if (chunk.type === 'content') {
536
546
  streamContent += chunk.content;
@@ -556,7 +566,7 @@ class AgentEngine {
556
566
  }
557
567
  }
558
568
  } else {
559
- response = await provider.chat(messages, tools, retryOptions);
569
+ response = await provider.chat(retryMessages, tools, retryOptions);
560
570
  responseModel = model;
561
571
  }
562
572
  } else {
@@ -704,7 +714,7 @@ class AgentEngine {
704
714
 
705
715
  if ((iteration >= maxIterations && messages[messages.length - 1]?.role === 'tool')
706
716
  || (iteration < maxIterations && stepIndex > 0 && !lastContent.trim() && messages[messages.length - 1]?.role !== 'tool')) {
707
- const finalResponse = await provider.chat(messages, [], {
717
+ const finalResponse = await provider.chat(sanitizeConversationMessages(messages), [], {
708
718
  model,
709
719
  reasoningEffort: this.getReasoningEffort(providerName, options)
710
720
  });
@@ -34,6 +34,67 @@ function normalizeHistoryRows(rows) {
34
34
  });
35
35
  }
36
36
 
37
+ function sanitizeConversationMessages(messages) {
38
+ const sanitized = [];
39
+ let pendingToolSequence = null;
40
+
41
+ const dropPendingSequence = () => {
42
+ pendingToolSequence = null;
43
+ };
44
+
45
+ const flushPendingSequence = () => {
46
+ if (!pendingToolSequence) return;
47
+ if (pendingToolSequence.pendingIds.size === 0) {
48
+ sanitized.push(...pendingToolSequence.messages);
49
+ }
50
+ pendingToolSequence = null;
51
+ };
52
+
53
+ for (const msg of messages || []) {
54
+ if (!msg || !msg.role) continue;
55
+
56
+ if (msg.role === 'assistant' && Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0) {
57
+ const toolCallIds = msg.tool_calls
58
+ .map((toolCall) => toolCall?.id)
59
+ .filter(Boolean);
60
+
61
+ if (toolCallIds.length === 0) {
62
+ dropPendingSequence();
63
+ sanitized.push(msg);
64
+ continue;
65
+ }
66
+
67
+ dropPendingSequence();
68
+ pendingToolSequence = {
69
+ messages: [msg],
70
+ pendingIds: new Set(toolCallIds)
71
+ };
72
+ continue;
73
+ }
74
+
75
+ if (msg.role === 'tool') {
76
+ if (
77
+ pendingToolSequence
78
+ && msg.tool_call_id
79
+ && pendingToolSequence.pendingIds.has(msg.tool_call_id)
80
+ ) {
81
+ pendingToolSequence.messages.push(msg);
82
+ pendingToolSequence.pendingIds.delete(msg.tool_call_id);
83
+ if (pendingToolSequence.pendingIds.size === 0) {
84
+ flushPendingSequence();
85
+ }
86
+ }
87
+ continue;
88
+ }
89
+
90
+ dropPendingSequence();
91
+ sanitized.push(msg);
92
+ }
93
+
94
+ flushPendingSequence();
95
+ return sanitized;
96
+ }
97
+
37
98
  function serializeHistoryForSummary(messages) {
38
99
  return messages.map((msg) => {
39
100
  if (msg.role === 'tool') {
@@ -143,7 +204,7 @@ function getConversationContext(conversationId, recentLimit) {
143
204
  return {
144
205
  summary: convo?.summary || '',
145
206
  summaryCount: Number(convo?.summary_message_count || 0),
146
- recentMessages: normalizeHistoryRows(recent),
207
+ recentMessages: sanitizeConversationMessages(normalizeHistoryRows(recent)),
147
208
  totalMessages: db.prepare('SELECT COUNT(*) AS count FROM conversation_messages WHERE conversation_id = ?').get(conversationId).count
148
209
  };
149
210
  }
@@ -184,5 +245,6 @@ module.exports = {
184
245
  getWebChatContext,
185
246
  refreshConversationSummary,
186
247
  refreshWebChatSummary,
248
+ sanitizeConversationMessages,
187
249
  summarizeMessages
188
250
  };
@@ -332,11 +332,11 @@ function getAvailableTools(app, options = {}) {
332
332
  },
333
333
  {
334
334
  name: 'android_install_apk',
335
- description: 'Install or replace an APK on the Android emulator.',
335
+ description: 'Install or replace an APK or universal .apks bundle on the Android emulator.',
336
336
  parameters: {
337
337
  type: 'object',
338
338
  properties: {
339
- apkPath: { type: 'string', description: 'Absolute path to the APK file on disk' }
339
+ apkPath: { type: 'string', description: 'Absolute path to an .apk file or universal .apks bundle on disk' }
340
340
  },
341
341
  required: ['apkPath']
342
342
  }
@@ -276,6 +276,49 @@ function extractZip(zipPath, destDir) {
276
276
  throw new Error('Neither unzip nor ditto is available to extract Android SDK archives');
277
277
  }
278
278
 
279
+ function listFilesRecursive(rootDir, predicate, bucket = []) {
280
+ for (const entry of fs.readdirSync(rootDir, { withFileTypes: true })) {
281
+ const fullPath = path.join(rootDir, entry.name);
282
+ if (entry.isDirectory()) {
283
+ listFilesRecursive(fullPath, predicate, bucket);
284
+ continue;
285
+ }
286
+ if (!predicate || predicate(fullPath, entry)) {
287
+ bucket.push(fullPath);
288
+ }
289
+ }
290
+ return bucket;
291
+ }
292
+
293
+ function resolveBundleInstallTargets(bundleDir) {
294
+ const apkFiles = listFilesRecursive(bundleDir, (filePath) => path.extname(filePath).toLowerCase() === '.apk')
295
+ .sort((a, b) => a.localeCompare(b));
296
+ if (apkFiles.length === 0) {
297
+ throw new Error('APK bundle did not contain any installable .apk files.');
298
+ }
299
+
300
+ const universalApk = apkFiles.find((filePath) => path.basename(filePath).toLowerCase() === 'universal.apk');
301
+ if (universalApk) {
302
+ return {
303
+ mode: 'single',
304
+ installPaths: [universalApk],
305
+ layout: 'universal',
306
+ };
307
+ }
308
+
309
+ if (apkFiles.length === 1) {
310
+ return {
311
+ mode: 'single',
312
+ installPaths: apkFiles,
313
+ layout: 'single-apk',
314
+ };
315
+ }
316
+
317
+ throw new Error(
318
+ 'APK bundles must include a universal APK. Export a universal .apks bundle or upload a single .apk instead.'
319
+ );
320
+ }
321
+
279
322
  function parseLatestCmdlineToolsUrl(xml) {
280
323
  const tag = platformTag() === 'mac' ? 'macosx' : 'linux';
281
324
  const packageMatch = xml.match(new RegExp(`<remotePackage\\s+path="cmdline-tools;latest">([\\s\\S]*?)<\\/remotePackage>`));
@@ -1162,16 +1205,18 @@ class AndroidController {
1162
1205
  }
1163
1206
  }
1164
1207
 
1165
- try {
1166
- const dump = await this.dumpUi({
1167
- serial: resolvedSerial,
1168
- includeNodes: options.includeNodes !== false,
1169
- });
1170
- observation.uiDumpPath = dump.uiDumpPath;
1171
- observation.nodeCount = dump.nodeCount;
1172
- observation.preview = dump.preview;
1173
- } catch (err) {
1174
- observation.observationWarnings.push(`ui_dump: ${err.message}`);
1208
+ if (options.uiDump !== false) {
1209
+ try {
1210
+ const dump = await this.dumpUi({
1211
+ serial: resolvedSerial,
1212
+ includeNodes: options.includeNodes !== false,
1213
+ });
1214
+ observation.uiDumpPath = dump.uiDumpPath;
1215
+ observation.nodeCount = dump.nodeCount;
1216
+ observation.preview = dump.preview;
1217
+ } catch (err) {
1218
+ observation.observationWarnings.push(`ui_dump: ${err.message}`);
1219
+ }
1175
1220
  }
1176
1221
 
1177
1222
  if (observation.observationWarnings.length === 0) {
@@ -1233,7 +1278,7 @@ class AndroidController {
1233
1278
  }
1234
1279
 
1235
1280
  await this.#adb(serial, `shell input tap ${Math.round(x)} ${Math.round(y)}`, { timeout: 15000 });
1236
- const observation = await this.#captureObservation(serial);
1281
+ const observation = await this.#captureObservation(serial, args);
1237
1282
  return {
1238
1283
  success: true,
1239
1284
  serial,
@@ -1267,7 +1312,7 @@ class AndroidController {
1267
1312
  `shell input swipe ${Math.round(x)} ${Math.round(y)} ${Math.round(x)} ${Math.round(y)} ${Math.round(durationMs)}`,
1268
1313
  { timeout: Math.max(15000, durationMs + 5000) },
1269
1314
  );
1270
- const observation = await this.#captureObservation(serial);
1315
+ const observation = await this.#captureObservation(serial, args);
1271
1316
  return {
1272
1317
  success: true,
1273
1318
  serial,
@@ -1294,6 +1339,8 @@ class AndroidController {
1294
1339
  description: args.description,
1295
1340
  className: args.className,
1296
1341
  clickable: true,
1342
+ screenshot: false,
1343
+ uiDump: false,
1297
1344
  }).catch(() => {});
1298
1345
  }
1299
1346
 
@@ -1301,7 +1348,7 @@ class AndroidController {
1301
1348
  if (args.pressEnter) {
1302
1349
  await this.#adb(serial, 'shell input keyevent 66', { timeout: 10000 });
1303
1350
  }
1304
- const observation = await this.#captureObservation(serial);
1351
+ const observation = await this.#captureObservation(serial, args);
1305
1352
  return {
1306
1353
  success: true,
1307
1354
  serial,
@@ -1321,7 +1368,7 @@ class AndroidController {
1321
1368
  throw new Error('x1, y1, x2, and y2 are required for android_swipe');
1322
1369
  }
1323
1370
  await this.#adb(serial, `shell input swipe ${Math.round(x1)} ${Math.round(y1)} ${Math.round(x2)} ${Math.round(y2)} ${Math.round(duration)}`, { timeout: 15000 });
1324
- const observation = await this.#captureObservation(serial);
1371
+ const observation = await this.#captureObservation(serial, args);
1325
1372
  return {
1326
1373
  success: true,
1327
1374
  serial,
@@ -1335,7 +1382,7 @@ class AndroidController {
1335
1382
  const keyCode = Number.isFinite(Number(raw)) ? Number(raw) : (DEFAULT_KEYEVENTS[raw] || null);
1336
1383
  if (!keyCode) throw new Error(`Unsupported Android key: ${args.key}`);
1337
1384
  await this.#adb(serial, `shell input keyevent ${keyCode}`, { timeout: 10000 });
1338
- const observation = await this.#captureObservation(serial);
1385
+ const observation = await this.#captureObservation(serial, args);
1339
1386
  return {
1340
1387
  success: true,
1341
1388
  serial,
@@ -1363,6 +1410,8 @@ class AndroidController {
1363
1410
  if (node) {
1364
1411
  const observation = await this.#captureObservation(dump.serial, {
1365
1412
  screenshot: args.screenshot !== false,
1413
+ uiDump: args.uiDump !== false,
1414
+ includeNodes: args.includeNodes,
1366
1415
  });
1367
1416
  return {
1368
1417
  success: true,
@@ -1400,7 +1449,7 @@ class AndroidController {
1400
1449
  } else {
1401
1450
  throw new Error('packageName is required for android_open_app');
1402
1451
  }
1403
- const observation = await this.#captureObservation(serial);
1452
+ const observation = await this.#captureObservation(serial, args);
1404
1453
  return {
1405
1454
  success: true,
1406
1455
  serial,
@@ -1426,7 +1475,7 @@ class AndroidController {
1426
1475
  }
1427
1476
 
1428
1477
  await this.#adb(serial, parts.join(' '), { timeout: 20000 });
1429
- const observation = await this.#captureObservation(serial);
1478
+ const observation = await this.#captureObservation(serial, args);
1430
1479
  return {
1431
1480
  success: true,
1432
1481
  serial,
@@ -1456,11 +1505,38 @@ class AndroidController {
1456
1505
  const apkPath = path.resolve(String(args.apkPath || ''));
1457
1506
  if (!apkPath || !fs.existsSync(apkPath)) throw new Error(`APK not found: ${apkPath}`);
1458
1507
  const serial = await this.ensureDevice();
1508
+ const extension = path.extname(apkPath).toLowerCase();
1509
+
1510
+ if (extension === '.aab') {
1511
+ throw new Error('.aab app bundles are not directly installable. Export a .apks bundle or .apk first.');
1512
+ }
1513
+
1514
+ if (extension === '.apks') {
1515
+ const extractDir = fs.mkdtempSync(path.join(TMP_DIR, 'apk-bundle-'));
1516
+ try {
1517
+ extractZip(apkPath, extractDir);
1518
+ const bundle = resolveBundleInstallTargets(extractDir);
1519
+ await this.#adb(serial, `install -r ${quoteShell(bundle.installPaths[0])}`, { timeout: 300000 });
1520
+ return {
1521
+ success: true,
1522
+ serial,
1523
+ apkPath,
1524
+ artifactType: 'apks',
1525
+ installedPaths: bundle.installPaths,
1526
+ bundleLayout: bundle.layout,
1527
+ };
1528
+ } finally {
1529
+ fs.rmSync(extractDir, { recursive: true, force: true });
1530
+ }
1531
+ }
1532
+
1459
1533
  await this.#adb(serial, `install -r ${quoteShell(apkPath)}`, { timeout: 300000 });
1460
1534
  return {
1461
1535
  success: true,
1462
1536
  serial,
1463
1537
  apkPath,
1538
+ artifactType: 'apk',
1539
+ installedPaths: [apkPath],
1464
1540
  };
1465
1541
  }
1466
1542
 
@@ -0,0 +1,54 @@
1
+ 'use strict';
2
+
3
+ const HEADLESS_BROWSER_SETTING_KEY = 'headless_browser';
4
+
5
+ function getErrorMessage(error) {
6
+ if (error instanceof Error && error.message) {
7
+ return error.message;
8
+ }
9
+
10
+ return String(error);
11
+ }
12
+
13
+ function isEnabledSetting(value) {
14
+ return value !== 'false' && value !== false && value !== '0';
15
+ }
16
+
17
+ function restoreBrowserHeadlessPreference(browserController, database) {
18
+ const userCount =
19
+ database.prepare('SELECT COUNT(*) AS count FROM users').get()?.count || 0;
20
+ const headlessSetting =
21
+ userCount === 1
22
+ ? database
23
+ .prepare(
24
+ 'SELECT value FROM user_settings WHERE user_id = (SELECT id FROM users LIMIT 1) AND key = ?',
25
+ )
26
+ .get(HEADLESS_BROWSER_SETTING_KEY)
27
+ : null;
28
+
29
+ if (!headlessSetting) {
30
+ return { restored: false, userCount };
31
+ }
32
+
33
+ browserController.headless = isEnabledSetting(headlessSetting.value);
34
+ return {
35
+ restored: true,
36
+ userCount,
37
+ headless: browserController.headless,
38
+ };
39
+ }
40
+
41
+ function runBackgroundTask(errorPrefix, task, logger = console.error) {
42
+ return Promise.resolve()
43
+ .then(task)
44
+ .catch((error) => {
45
+ logger(errorPrefix, getErrorMessage(error));
46
+ });
47
+ }
48
+
49
+ module.exports = {
50
+ getErrorMessage,
51
+ isEnabledSetting,
52
+ restoreBrowserHeadlessPreference,
53
+ runBackgroundTask,
54
+ };