neoagent 2.1.10 → 2.1.12

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.
@@ -1,10 +1,45 @@
1
1
  const express = require('express');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const multer = require('multer');
2
5
  const router = express.Router();
6
+ const { DATA_DIR } = require('../../runtime/paths');
3
7
  const { requireAuth } = require('../middleware/auth');
4
8
  const { sanitizeError } = require('../utils/security');
5
9
 
6
10
  router.use(requireAuth);
7
11
 
12
+ const androidApkUploadDir = path.join(DATA_DIR, 'uploads', 'android-apks');
13
+ fs.mkdirSync(androidApkUploadDir, { recursive: true });
14
+
15
+ const androidApkUpload = multer({
16
+ storage: multer.diskStorage({
17
+ destination: (_req, _file, cb) => cb(null, androidApkUploadDir),
18
+ filename: (_req, file, cb) => {
19
+ const extension = path.extname(file.originalname || '').toLowerCase();
20
+ const stem = path.basename(file.originalname || 'upload', extension)
21
+ .replace(/[^a-z0-9._-]+/gi, '-')
22
+ .replace(/^-+|-+$/g, '')
23
+ .slice(0, 64) || 'upload';
24
+ cb(
25
+ null,
26
+ `${Date.now()}-${Math.random().toString(16).slice(2)}-${stem}${extension || '.apk'}`
27
+ );
28
+ },
29
+ }),
30
+ fileFilter: (_req, file, cb) => {
31
+ if (!String(file.originalname || '').toLowerCase().endsWith('.apk')) {
32
+ cb(new Error('Only .apk files can be installed.'));
33
+ return;
34
+ }
35
+ cb(null, true);
36
+ },
37
+ limits: {
38
+ fileSize: 512 * 1024 * 1024,
39
+ files: 1,
40
+ },
41
+ });
42
+
8
43
  router.get('/status', async (req, res) => {
9
44
  try {
10
45
  const controller = req.app.locals.androidController;
@@ -97,6 +132,15 @@ router.post('/tap', async (req, res) => {
97
132
  }
98
133
  });
99
134
 
135
+ router.post('/long-press', async (req, res) => {
136
+ try {
137
+ const controller = req.app.locals.androidController;
138
+ res.json(await controller.longPress(req.body || {}));
139
+ } catch (err) {
140
+ res.status(500).json({ error: sanitizeError(err) });
141
+ }
142
+ });
143
+
100
144
  router.post('/type', async (req, res) => {
101
145
  try {
102
146
  const controller = req.app.locals.androidController;
@@ -133,4 +177,47 @@ router.post('/wait-for', async (req, res) => {
133
177
  }
134
178
  });
135
179
 
180
+ router.post('/install-apk', (req, res) => {
181
+ androidApkUpload.single('apk')(req, res, async (uploadError) => {
182
+ if (uploadError) {
183
+ const message =
184
+ uploadError instanceof multer.MulterError &&
185
+ uploadError.code === 'LIMIT_FILE_SIZE'
186
+ ? 'APK upload is too large. Limit is 512MB.'
187
+ : sanitizeError(uploadError);
188
+ res.status(400).json({ error: message });
189
+ return;
190
+ }
191
+
192
+ const uploadedApkPath = req.file?.path;
193
+ if (!uploadedApkPath) {
194
+ res.status(400).json({ error: 'No APK file was uploaded.' });
195
+ return;
196
+ }
197
+
198
+ try {
199
+ const controller = req.app.locals.androidController;
200
+ const result = await controller.installApk({ apkPath: uploadedApkPath });
201
+ res.json({
202
+ ...result,
203
+ filename: req.file.originalname,
204
+ size: req.file.size,
205
+ });
206
+ } catch (err) {
207
+ res.status(500).json({ error: sanitizeError(err) });
208
+ } finally {
209
+ fs.promises.unlink(uploadedApkPath).catch(() => {});
210
+ }
211
+ });
212
+ });
213
+
214
+ router.post('/shell', async (req, res) => {
215
+ try {
216
+ const controller = req.app.locals.androidController;
217
+ res.json(await controller.shell(req.body || {}));
218
+ } catch (err) {
219
+ res.status(500).json({ error: sanitizeError(err) });
220
+ }
221
+ });
222
+
136
223
  module.exports = router;
@@ -6,6 +6,8 @@ const { getConversationContext, buildSummaryCarrier, refreshConversationSummary
6
6
  const { ensureDefaultAiSettings, getAiSettings } = require('./settings');
7
7
  const { selectToolsForTask } = require('./toolSelector');
8
8
  const { compactToolResult } = require('./toolResult');
9
+ const { salvageTextToolCalls } = require('./toolCallSalvage');
10
+ const { sanitizeModelOutput } = require('./outputSanitizer');
9
11
 
10
12
  function generateTitle(task) {
11
13
  if (!task || typeof task !== 'string') return 'Untitled';
@@ -299,13 +301,16 @@ class AgentEngine {
299
301
  });
300
302
  }
301
303
  };
302
- const { provider, model, providerName } = await getProviderForUser(
304
+ const selectedProvider = await getProviderForUser(
303
305
  userId,
304
306
  userMessage,
305
307
  triggerType === 'subagent',
306
308
  _modelOverride,
307
309
  providerStatusConfig
308
310
  );
311
+ let provider = selectedProvider.provider;
312
+ let model = selectedProvider.model;
313
+ let providerName = selectedProvider.providerName;
309
314
 
310
315
  const runTitle = generateTitle(userMessage);
311
316
  db.prepare(`INSERT OR REPLACE INTO agent_runs(id, user_id, title, status, trigger_type, trigger_source, model)
@@ -380,6 +385,7 @@ class AgentEngine {
380
385
  this.emit(userId, 'run:thinking', { runId, iteration });
381
386
 
382
387
  let response;
388
+ let responseModel = model;
383
389
  let streamContent = '';
384
390
  const callOptions = { model, reasoningEffort: this.getReasoningEffort(providerName, options) };
385
391
 
@@ -390,22 +396,30 @@ class AgentEngine {
390
396
  for await (const chunk of gen) {
391
397
  if (chunk.type === 'content') {
392
398
  streamContent += chunk.content;
393
- this.emit(userId, 'run:stream', { runId, content: streamContent, iteration });
399
+ this.emit(userId, 'run:stream', {
400
+ runId,
401
+ content: sanitizeModelOutput(streamContent, { model }),
402
+ iteration
403
+ });
394
404
  }
395
405
  if (chunk.type === 'done') {
396
406
  response = chunk;
407
+ responseModel = model;
397
408
  }
398
409
  if (chunk.type === 'tool_calls') {
399
410
  response = {
400
411
  content: chunk.content || streamContent,
401
412
  toolCalls: chunk.toolCalls,
413
+ providerContentBlocks: chunk.providerContentBlocks || null,
402
414
  finishReason: 'tool_calls',
403
415
  usage: chunk.usage || null
404
416
  };
417
+ responseModel = model;
405
418
  }
406
419
  }
407
420
  } else {
408
421
  response = await provider.chat(messages, tools, callOptions);
422
+ responseModel = model;
409
423
  }
410
424
  } catch (err) {
411
425
  console.error(`[Engine] Model call failed (${model}):`, err.message);
@@ -418,33 +432,42 @@ class AgentEngine {
418
432
  aiSettings.fallback_model_id,
419
433
  providerStatusConfig
420
434
  );
421
- // Update local state for the retry
422
- const nextProvider = fallback.provider;
423
- const nextModel = fallback.model;
424
- const nextProviderName = fallback.providerName;
435
+ provider = fallback.provider;
436
+ model = fallback.model;
437
+ providerName = fallback.providerName;
425
438
 
426
439
  // Recursive call once
427
- const retryOptions = { ...callOptions, model: nextModel, reasoningEffort: this.getReasoningEffort(nextProviderName, options) };
440
+ const retryOptions = { ...callOptions, model, reasoningEffort: this.getReasoningEffort(providerName, options) };
428
441
 
429
442
  if (options.stream !== false) {
430
- const gen = nextProvider.stream(messages, tools, retryOptions);
443
+ const gen = provider.stream(messages, tools, retryOptions);
431
444
  for await (const chunk of gen) {
432
445
  if (chunk.type === 'content') {
433
446
  streamContent += chunk.content;
434
- this.emit(userId, 'run:stream', { runId, content: streamContent, iteration });
447
+ this.emit(userId, 'run:stream', {
448
+ runId,
449
+ content: sanitizeModelOutput(streamContent, { model }),
450
+ iteration
451
+ });
452
+ }
453
+ if (chunk.type === 'done') {
454
+ response = chunk;
455
+ responseModel = model;
435
456
  }
436
- if (chunk.type === 'done') response = chunk;
437
457
  if (chunk.type === 'tool_calls') {
438
458
  response = {
439
459
  content: chunk.content || streamContent,
440
460
  toolCalls: chunk.toolCalls,
461
+ providerContentBlocks: chunk.providerContentBlocks || null,
441
462
  finishReason: 'tool_calls',
442
463
  usage: chunk.usage || null
443
464
  };
465
+ responseModel = model;
444
466
  }
445
467
  }
446
468
  } else {
447
- response = await nextProvider.chat(messages, tools, retryOptions);
469
+ response = await provider.chat(messages, tools, retryOptions);
470
+ responseModel = model;
448
471
  }
449
472
  } else {
450
473
  throw err;
@@ -462,10 +485,21 @@ class AgentEngine {
462
485
  totalTokens += response.usage.totalTokens || 0;
463
486
  }
464
487
 
465
- lastContent = response.content || streamContent || '';
488
+ lastContent = sanitizeModelOutput(response.content || streamContent || '', { model: responseModel });
489
+
490
+ if ((!response.toolCalls || response.toolCalls.length === 0) && lastContent) {
491
+ const salvaged = salvageTextToolCalls(lastContent, tools);
492
+ if (salvaged.toolCalls.length > 0) {
493
+ response.toolCalls = salvaged.toolCalls;
494
+ response.finishReason = 'tool_calls';
495
+ response.content = salvaged.content;
496
+ lastContent = salvaged.content;
497
+ }
498
+ }
466
499
 
467
500
  const assistantMessage = { role: 'assistant', content: lastContent };
468
501
  if (response.toolCalls?.length) assistantMessage.tool_calls = response.toolCalls;
502
+ if (response.providerContentBlocks?.length) assistantMessage.providerContentBlocks = response.providerContentBlocks;
469
503
  messages.push(assistantMessage);
470
504
 
471
505
  if (conversationId) {
@@ -572,10 +606,14 @@ class AgentEngine {
572
606
  model,
573
607
  reasoningEffort: this.getReasoningEffort(providerName, options)
574
608
  });
575
- lastContent = finalResponse.content || '';
609
+ lastContent = sanitizeModelOutput(finalResponse.content || '', { model });
576
610
  forcedFinalResponse = true;
577
611
 
578
- messages.push({ role: 'assistant', content: lastContent });
612
+ const finalAssistantMessage = { role: 'assistant', content: lastContent };
613
+ if (finalResponse.providerContentBlocks?.length) {
614
+ finalAssistantMessage.providerContentBlocks = finalResponse.providerContentBlocks;
615
+ }
616
+ messages.push(finalAssistantMessage);
579
617
  if (conversationId) {
580
618
  db.prepare('INSERT INTO conversation_messages (conversation_id, role, content, tokens) VALUES (?, ?, ?, ?)')
581
619
  .run(conversationId, 'assistant', lastContent, finalResponse.usage?.totalTokens || 0);
@@ -0,0 +1,67 @@
1
+ const { sanitizeStreamingToolCallText } = require('./toolCallSalvage');
2
+
3
+ const HAN_CHAR_REGEX = /\p{Script=Han}/gu;
4
+ const LATIN_CHAR_REGEX = /\p{Script=Latin}/gu;
5
+ const LETTER_CHAR_REGEX = /\p{L}/gu;
6
+ const HAN_RUN_REGEX = /[\p{Script=Han}\u3000-\u303F]+/gu;
7
+ const MARKDOWN_CODE_SPAN_REGEX = /(```[\s\S]*?```|`[^`\n]+`)/g;
8
+
9
+ function countMatches(text, regex) {
10
+ const matches = text.match(regex);
11
+ return matches ? matches.length : 0;
12
+ }
13
+
14
+ function shouldStripIncidentalHan(text, model) {
15
+ if (model !== 'MiniMax-M2.7') return false;
16
+
17
+ const hanCount = countMatches(text, HAN_CHAR_REGEX);
18
+ if (hanCount === 0) return false;
19
+ if (hanCount > 24) return false;
20
+
21
+ const latinCount = countMatches(text, LATIN_CHAR_REGEX);
22
+ if (latinCount < 20) return false;
23
+
24
+ const letterCount = countMatches(text, LETTER_CHAR_REGEX);
25
+ if (letterCount > 0 && (hanCount / letterCount) > 0.18) return false;
26
+
27
+ return true;
28
+ }
29
+
30
+ function sanitizePlainText(text) {
31
+ return text
32
+ .replace(/([\p{L}\p{N}])[\p{Script=Han}\u3000-\u303F]+([\p{L}\p{N}])/gu, '$1 $2')
33
+ .replace(HAN_RUN_REGEX, '')
34
+ .replace(/[ \t]{2,}/g, ' ')
35
+ .replace(/[ \t]+\n/g, '\n')
36
+ .replace(/\n[ \t]+/g, '\n')
37
+ .replace(/[ \t]+([,.;:!?)\]}])/g, '$1')
38
+ .replace(/([([{])\s+/g, '$1');
39
+ }
40
+
41
+ function sanitizeMarkdownAware(text) {
42
+ return text
43
+ .split(MARKDOWN_CODE_SPAN_REGEX)
44
+ .map((part) => {
45
+ if (!part) return part;
46
+ if (part.startsWith('```') || part.startsWith('`')) return part;
47
+ return sanitizePlainText(part);
48
+ })
49
+ .join('');
50
+ }
51
+
52
+ function sanitizeModelOutput(text, options = {}) {
53
+ if (typeof text !== 'string' || text.length === 0) return text;
54
+
55
+ let sanitized = text;
56
+
57
+ if (options.model === 'MiniMax-M2.7' && (sanitized.includes('<invoke') || sanitized.includes(':tool_call'))) {
58
+ sanitized = sanitizeStreamingToolCallText(sanitized);
59
+ }
60
+
61
+ if (!shouldStripIncidentalHan(sanitized, options.model)) return sanitized;
62
+ return sanitizeMarkdownAware(sanitized);
63
+ }
64
+
65
+ module.exports = {
66
+ sanitizeModelOutput
67
+ };
@@ -37,6 +37,50 @@ class AnthropicProvider extends BaseProvider {
37
37
  }));
38
38
  }
39
39
 
40
+ normalizeContentBlocks(blocks = []) {
41
+ const normalized = [];
42
+
43
+ for (const block of blocks) {
44
+ if (!block || !block.type) continue;
45
+
46
+ if (block.type === 'thinking') {
47
+ normalized.push({
48
+ type: 'thinking',
49
+ thinking: block.thinking || '',
50
+ ...(block.signature ? { signature: block.signature } : {})
51
+ });
52
+ continue;
53
+ }
54
+
55
+ if (block.type === 'redacted_thinking') {
56
+ normalized.push({
57
+ type: 'redacted_thinking',
58
+ data: block.data
59
+ });
60
+ continue;
61
+ }
62
+
63
+ if (block.type === 'text') {
64
+ normalized.push({
65
+ type: 'text',
66
+ text: block.text || ''
67
+ });
68
+ continue;
69
+ }
70
+
71
+ if (block.type === 'tool_use') {
72
+ normalized.push({
73
+ type: 'tool_use',
74
+ id: block.id,
75
+ name: block.name,
76
+ input: block.input || {}
77
+ });
78
+ }
79
+ }
80
+
81
+ return normalized;
82
+ }
83
+
40
84
  convertMessages(messages) {
41
85
  let system = '';
42
86
  const converted = [];
@@ -60,6 +104,14 @@ class AnthropicProvider extends BaseProvider {
60
104
  }
61
105
 
62
106
  if (msg.role === 'assistant' && msg.tool_calls) {
107
+ if (Array.isArray(msg.providerContentBlocks) && msg.providerContentBlocks.length > 0) {
108
+ converted.push({
109
+ role: 'assistant',
110
+ content: this.normalizeContentBlocks(msg.providerContentBlocks)
111
+ });
112
+ continue;
113
+ }
114
+
63
115
  const content = [];
64
116
  if (msg.content) content.push({ type: 'text', text: msg.content });
65
117
  for (const tc of msg.tool_calls) {
@@ -100,6 +152,7 @@ class AnthropicProvider extends BaseProvider {
100
152
 
101
153
  let content = '';
102
154
  const toolCalls = [];
155
+ const providerContentBlocks = this.normalizeContentBlocks(response.content);
103
156
 
104
157
  for (const block of response.content) {
105
158
  if (block.type === 'text') {
@@ -119,6 +172,7 @@ class AnthropicProvider extends BaseProvider {
119
172
  return {
120
173
  content,
121
174
  toolCalls,
175
+ providerContentBlocks,
122
176
  finishReason: response.stop_reason === 'tool_use' ? 'tool_calls' : 'stop',
123
177
  usage: {
124
178
  promptTokens: response.usage.input_tokens,
@@ -148,31 +202,106 @@ class AnthropicProvider extends BaseProvider {
148
202
  let content = '';
149
203
  let currentToolCalls = [];
150
204
  let currentToolIndex = -1;
205
+ const providerContentBlocks = [];
151
206
 
152
207
  for await (const event of stream) {
153
208
  if (event.type === 'content_block_start') {
154
- if (event.content_block.type === 'tool_use') {
209
+ if (event.content_block.type === 'thinking') {
210
+ providerContentBlocks[event.index] = {
211
+ type: 'thinking',
212
+ thinking: event.content_block.thinking || '',
213
+ signature: event.content_block.signature || ''
214
+ };
215
+ } else if (event.content_block.type === 'redacted_thinking') {
216
+ providerContentBlocks[event.index] = {
217
+ type: 'redacted_thinking',
218
+ data: event.content_block.data
219
+ };
220
+ } else if (event.content_block.type === 'text') {
221
+ providerContentBlocks[event.index] = {
222
+ type: 'text',
223
+ text: event.content_block.text || ''
224
+ };
225
+ } else if (event.content_block.type === 'tool_use') {
155
226
  currentToolIndex++;
156
227
  currentToolCalls.push({
157
228
  id: event.content_block.id,
158
229
  type: 'function',
159
230
  function: { name: event.content_block.name, arguments: '' }
160
231
  });
232
+ providerContentBlocks[event.index] = {
233
+ type: 'tool_use',
234
+ id: event.content_block.id,
235
+ name: event.content_block.name,
236
+ input: {}
237
+ };
161
238
  }
162
239
  } else if (event.type === 'content_block_delta') {
163
240
  if (event.delta.type === 'text_delta') {
164
241
  content += event.delta.text;
242
+ if (providerContentBlocks[event.index]?.type === 'text') {
243
+ providerContentBlocks[event.index].text += event.delta.text;
244
+ }
165
245
  yield { type: 'content', content: event.delta.text };
246
+ } else if (event.delta.type === 'thinking_delta') {
247
+ if (providerContentBlocks[event.index]?.type === 'thinking') {
248
+ providerContentBlocks[event.index].thinking += event.delta.thinking || '';
249
+ }
250
+ } else if (event.delta.type === 'signature_delta') {
251
+ if (providerContentBlocks[event.index]?.type === 'thinking') {
252
+ providerContentBlocks[event.index].signature = event.delta.signature || '';
253
+ }
166
254
  } else if (event.delta.type === 'input_json_delta') {
167
255
  if (currentToolCalls[currentToolIndex]) {
168
256
  currentToolCalls[currentToolIndex].function.arguments += event.delta.partial_json;
169
257
  }
258
+ if (providerContentBlocks[event.index]?.type === 'tool_use') {
259
+ const currentJson = providerContentBlocks[event.index]._inputJson || '';
260
+ providerContentBlocks[event.index]._inputJson = currentJson + (event.delta.partial_json || '');
261
+ }
170
262
  }
171
263
  } else if (event.type === 'message_stop') {
264
+ const normalizedBlocks = providerContentBlocks
265
+ .filter(Boolean)
266
+ .map((block) => {
267
+ if (block.type === 'tool_use') {
268
+ let parsedInput = block.input || {};
269
+ if (typeof block._inputJson === 'string' && block._inputJson.trim()) {
270
+ try {
271
+ parsedInput = JSON.parse(block._inputJson);
272
+ } catch { }
273
+ }
274
+ return {
275
+ type: 'tool_use',
276
+ id: block.id,
277
+ name: block.name,
278
+ input: parsedInput
279
+ };
280
+ }
281
+ if (block.type === 'thinking') {
282
+ return {
283
+ type: 'thinking',
284
+ thinking: block.thinking || '',
285
+ ...(block.signature ? { signature: block.signature } : {})
286
+ };
287
+ }
288
+ if (block.type === 'redacted_thinking') {
289
+ return {
290
+ type: 'redacted_thinking',
291
+ data: block.data
292
+ };
293
+ }
294
+ return {
295
+ type: 'text',
296
+ text: block.text || ''
297
+ };
298
+ });
299
+
172
300
  yield {
173
301
  type: 'done',
174
302
  content,
175
303
  toolCalls: currentToolCalls,
304
+ providerContentBlocks: normalizedBlocks,
176
305
  finishReason: currentToolCalls.length > 0 ? 'tool_calls' : 'stop',
177
306
  usage: null
178
307
  };
@@ -56,6 +56,7 @@ The tools listed in this call are exactly what you have. Trust the list. If a to
56
56
 
57
57
  SHELL COMMANDS
58
58
  When you use execute_command, treat timed out or killed commands as unfinished work, not success. For installs, updates, restarts, config changes, or other state-changing shell actions, verify the outcome with a follow-up command before telling the user it is done.
59
+ If you restart or stop the NeoAgent service, this run ends immediately. Warn the user before doing it and say you cannot continue the current run after the restart.
59
60
 
60
61
  SKILLS
61
62
  If a multi-step task produces a reusable pattern, save or improve it as a skill when appropriate.
@@ -0,0 +1,142 @@
1
+ const { v4: uuidv4 } = require('uuid')
2
+
3
+ const INVOKE_OPEN_RE = /(?:[A-Za-z0-9_.-]+:tool_call\s*)?<invoke\s+name="([^"]+)">/g
4
+ const PARAM_OPEN_RE = /<parameter\s+name="([^"]+)">/g
5
+ const PARAM_CLOSED_RE = /<parameter\s+name="([^"]+)">([\s\S]*?)<\/parameter>/g
6
+ const TOOL_WRAPPER_RE = /<\/?[A-Za-z0-9_.-]+:tool_call>/g
7
+ const COMPLETE_INLINE_CALL_RE = /(?:[A-Za-z0-9_.-]+:tool_call\s*)?<invoke\s+name="[^"]+">[\s\S]*?<\/invoke>/g
8
+ const INVOKE_CLOSE = '</invoke>'
9
+
10
+ function trimLooseControlText(text) {
11
+ return String(text || '')
12
+ .replace(TOOL_WRAPPER_RE, '')
13
+ .replace(/(?:^|\s)[A-Za-z0-9_.-]+:tool_call\s*$/g, '')
14
+ .trim()
15
+ }
16
+
17
+ function sanitizeStreamingToolCallText(text) {
18
+ let visible = String(text || '')
19
+ .replace(COMPLETE_INLINE_CALL_RE, '')
20
+ .replace(TOOL_WRAPPER_RE, '')
21
+
22
+ const partialStarts = [
23
+ visible.lastIndexOf('<invoke'),
24
+ visible.lastIndexOf(':tool_call')
25
+ ].filter((index) => index >= 0)
26
+
27
+ if (partialStarts.length > 0) {
28
+ const partialStart = Math.max(...partialStarts)
29
+ const suffix = visible.slice(partialStart)
30
+ if (!suffix.includes(INVOKE_CLOSE)) {
31
+ visible = visible.slice(0, partialStart)
32
+ }
33
+ }
34
+
35
+ return trimLooseControlText(visible).replace(/\n{3,}/g, '\n\n')
36
+ }
37
+
38
+ function parseParameterMap(body) {
39
+ const args = {}
40
+ let sawClosedParam = false
41
+ let match
42
+
43
+ PARAM_CLOSED_RE.lastIndex = 0
44
+ while ((match = PARAM_CLOSED_RE.exec(body)) !== null) {
45
+ sawClosedParam = true
46
+ args[match[1]] = match[2].trim()
47
+ }
48
+ if (sawClosedParam) return args
49
+
50
+ const markers = []
51
+ PARAM_OPEN_RE.lastIndex = 0
52
+ while ((match = PARAM_OPEN_RE.exec(body)) !== null) {
53
+ markers.push({
54
+ name: match[1],
55
+ valueStart: match.index + match[0].length,
56
+ openIndex: match.index
57
+ })
58
+ }
59
+
60
+ for (let i = 0; i < markers.length; i++) {
61
+ const current = markers[i]
62
+ const next = markers[i + 1]
63
+ const rawValue = body.slice(current.valueStart, next ? next.openIndex : body.length)
64
+ args[current.name] = rawValue.trim()
65
+ }
66
+
67
+ return args
68
+ }
69
+
70
+ function salvageTextToolCalls(content, tools = []) {
71
+ const text = String(content || '')
72
+ if (!text.includes('<invoke')) {
73
+ return { content: text, toolCalls: [] }
74
+ }
75
+
76
+ const allowedToolNames = new Set(
77
+ Array.isArray(tools) ? tools.map((tool) => tool?.name).filter(Boolean) : []
78
+ )
79
+
80
+ const openings = []
81
+ let match
82
+ INVOKE_OPEN_RE.lastIndex = 0
83
+ while ((match = INVOKE_OPEN_RE.exec(text)) !== null) {
84
+ openings.push({
85
+ index: match.index,
86
+ openText: match[0],
87
+ name: match[1]
88
+ })
89
+ }
90
+
91
+ if (openings.length === 0) {
92
+ return { content: text, toolCalls: [] }
93
+ }
94
+
95
+ const cleanedParts = []
96
+ const toolCalls = []
97
+ let cursor = 0
98
+
99
+ for (let i = 0; i < openings.length; i++) {
100
+ const current = openings[i]
101
+ const next = openings[i + 1]
102
+ const searchEnd = next ? next.index : text.length
103
+ const bodyStart = current.index + current.openText.length
104
+ const closeIndex = text.indexOf(INVOKE_CLOSE, bodyStart)
105
+ const blockEnd = closeIndex !== -1 && closeIndex < searchEnd
106
+ ? closeIndex + INVOKE_CLOSE.length
107
+ : searchEnd
108
+ const bodyEnd = closeIndex !== -1 && closeIndex < searchEnd
109
+ ? closeIndex
110
+ : searchEnd
111
+
112
+ cleanedParts.push(text.slice(cursor, current.index))
113
+ cursor = blockEnd
114
+
115
+ if (allowedToolNames.size > 0 && !allowedToolNames.has(current.name)) {
116
+ cleanedParts.push(text.slice(current.index, blockEnd))
117
+ continue
118
+ }
119
+
120
+ const args = parseParameterMap(text.slice(bodyStart, bodyEnd))
121
+ toolCalls.push({
122
+ id: `salvaged_${uuidv4()}`,
123
+ type: 'function',
124
+ function: {
125
+ name: current.name,
126
+ arguments: JSON.stringify(args)
127
+ }
128
+ })
129
+ }
130
+
131
+ cleanedParts.push(text.slice(cursor))
132
+
133
+ return {
134
+ content: trimLooseControlText(cleanedParts.join('').replace(/\n{3,}/g, '\n\n')),
135
+ toolCalls
136
+ }
137
+ }
138
+
139
+ module.exports = {
140
+ salvageTextToolCalls,
141
+ sanitizeStreamingToolCallText
142
+ }
@@ -108,6 +108,16 @@ function compactToolResult(toolName, toolArgs = {}, toolResult, options = {}) {
108
108
  });
109
109
  break;
110
110
 
111
+ case 'android_shell':
112
+ envelope = trimObject({
113
+ tool: toolName,
114
+ serial: toolResult?.serial,
115
+ command: toolArgs.command,
116
+ screenshotPath: toolResult?.screenshotPath,
117
+ excerpt: lineExcerpt(toolResult?.stdout || toolResult?.result || toolResult, 18, Math.floor(softLimit * 0.65))
118
+ });
119
+ break;
120
+
111
121
  case 'http_request':
112
122
  envelope = trimObject({
113
123
  tool: toolName,