zames_pro 2.70.0 → 2.72.0

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.
@@ -0,0 +1,765 @@
1
+ import { parseXmlToolCalls } from './xml-toolcall.js';
2
+ import { normText } from './browser-pure.js';
3
+ // Pure, state-free helpers extracted from agent-loop.ts (BACKLOG C3): the
4
+ // tool-call PARSER (JSON / permissive / XML-DSML repair) and the small answer
5
+ // heuristics. None of them touch the browser or the loop state, so this
6
+ // module is safe to import on its own. agent-loop.ts imports the ones the
7
+ // loop needs and RE-EXPORTS the public ones, so the existing API (tests import
8
+ // parseToolCall / responseLooksLikeToolCall / truncateToolResult from './agent-loop.js')
9
+ // is unchanged.
10
+ // Cap a tool result for the model, but make the truncation EXPLICIT: a bare
11
+ // slice() silently hid the tail and the model had no idea it was looking at a
12
+ // partial output (it would act on a half-read file/log).
13
+ export function truncateToolResult(text, limit) {
14
+ if (text.length <= limit)
15
+ return text;
16
+ // Prefer a LINE boundary near the limit so the model does not read a line
17
+ // cut in half (a half line is easy to misread as the real content). We look
18
+ // for the last newline within the last 20% of the window; if none, fall back
19
+ // to the hard char cut.
20
+ let cut = limit;
21
+ const NL = String.fromCharCode(10);
22
+ const searchFrom = Math.floor(limit * 0.8);
23
+ const nl = text.lastIndexOf(NL, limit);
24
+ // Cut AFTER the newline (include it) so the kept part is a whole number of
25
+ // lines — a cut that EXCLUDED the newline left a half line before the marker.
26
+ if (nl >= searchFrom)
27
+ cut = nl + 1;
28
+ const omitted = text.length - cut;
29
+ return text.slice(0, cut) + NL + `[...truncated ${omitted} chars]`;
30
+ }
31
+ // The answer looks like a tool call, but parseToolCall() did not recognize it.
32
+ // Used as a safeguard against "the agent called a tool and stopped": in that
33
+ // case runAgentLoop asks the model to resend the call instead of finishing the
34
+ // task. We catch both explicit formats and "broken" call heads
35
+ // (`<|tool": ...`, `**tool**:`, `tool": ...`), and truncated calls.
36
+ export function responseLooksLikeToolCall(rawResponse) {
37
+ const raw = rawResponse || '';
38
+ return (
39
+ // Explicit tool-call format markers: the JSON key "tool", XML/DSML tags,
40
+ // function_call, etc.
41
+ /("tool"\s*:|\btool_calls?\b|\binvoke\b|\bparameter\b|DSML|function_call)/i.test(raw) ||
42
+ // "tool" without an opening quote/bracket, with a junk prefix
43
+ // (`<|tool":`, `**tool**:`, `- tool:`): a call key, not prose.
44
+ /(^|[^A-Za-z0-9_])(?:\*\*)?tool(?:\*\*)?["'`\u2018\u2019\u201c\u201d]*\s*:/.test(raw) ||
45
+ // Keys in single quotes or unquoted: {'tool': 'Read', ...}.
46
+ /[\{\[]\s*['"]?(tool|name|args)['"]?\s*:/.test(raw) ||
47
+ // Truncated call: starts like a JSON object but is not closed, and has an
48
+ // argument key (args/command/path/...). We require the opening bracket at
49
+ // the start (after spaces/prefix) so we don't catch ordinary prose with
50
+ // colons like "path: ...".
51
+ (/^\s*[\[\{]/.test(raw) &&
52
+ /["']?(?:tool|args|command|path|old_string|content|content_base64)["']?\s*:/.test(raw)) ||
53
+ /<\s*\|?\s*(DSML|invoke|parameter)/i.test(raw) ||
54
+ /^\s*\[?\s*\{[^}]*$/.test(raw.trim()));
55
+ }
56
+ // A respond message is only a real FINAL answer when it carries some meaning.
57
+ // DeepSeek sometimes finishes with a placeholder — "...", "-", "ok", "done",
58
+ // "готово" — which looks like a stop with no report. Such a respond must not
59
+ // end the task silently: it is treated like an empty one (re-ask).
60
+ export function isMeaningfulRespond(msg) {
61
+ const t = (msg || '').trim();
62
+ if (!t)
63
+ return false;
64
+ // Only punctuation/dots/ellipses: "...", "---", "…", "?" — not a report.
65
+ if (/^[.…–—_*?!/\s-]+$/.test(t))
66
+ return false;
67
+ // A bare acknowledgement with no content at all. NOTE: "ok"/"done"/
68
+ // "готово" are NOT in this list: a short "готово" is a legitimate final
69
+ // answer for a small task, and dropping it re-opened the loop.
70
+ if (/^(na|null|undefined)[.!]*$/i.test(t))
71
+ return false;
72
+ return true;
73
+ }
74
+ // Normalize an answer for STALE comparison: collapse whitespace AND strip
75
+ // markdown emphasis/code markers. DeepSeek echoes the previous turn with a
76
+ // different emphasis (`**done**` vs `done`), which defeated an exact match and
77
+ // made the loop re-run the same tool ("stopped after a tool call").
78
+ export function normForStale(s) {
79
+ return normText(s)
80
+ .replace(/[*_`#>]+/g, '')
81
+ .replace(/[ \t]+/g, ' ')
82
+ .trim();
83
+ }
84
+ // Text that promises a tool call in the future tense but contains no call
85
+ // itself. DeepSeek regularly "hangs" like this: it writes
86
+ // "Now update README to mention …", "Let me run the tests", "I'll check now"
87
+ // and stops. Such answers must not be taken as final — otherwise the agent
88
+ // stalls without doing the work. We keep the heuristic narrow (future tense /
89
+ // intent) so we don't catch ordinary reports of completed work.
90
+ export function looksLikeUnfinishedWork(text) {
91
+ const t = (text || '').trim();
92
+ if (!t)
93
+ return false;
94
+ // Long answers (reports) are left alone — anything can be in there.
95
+ if (t.length > 600)
96
+ return false;
97
+ // A final marker is already present — treat the answer as complete.
98
+ if (/\b(done|finished|completed|готово|выполнено|завершено)\b/i.test(t)) {
99
+ return false;
100
+ }
101
+ const en = /\b(now|next|then|let me|let's|i will|i'll|i am going to|i'm going to|going to|about to|will now|time to)\b[^.!?\n]{0,120}\b(update|write|edit|read|run|check|add|fix|create|remove|delete|apply|test|commit|push|install|open|search|look|verify|change|modify|implement|review)\b/i;
102
+ const ru = /(^|[^а-яё])(проверю|обновлю|исправлю|добавлю|запущу|выполню|посмотрю|прочитаю|изменю|попробую|сделаю)([^а-яё]|$)/i;
103
+ return en.test(t) || ru.test(t);
104
+ }
105
+ // ============ JSON parsing ============
106
+ function repairRawControlChars(str) {
107
+ let out = '';
108
+ let inString = false;
109
+ let escape = false;
110
+ for (let i = 0; i < str.length; i++) {
111
+ const c = str[i];
112
+ if (escape) {
113
+ out += c;
114
+ escape = false;
115
+ continue;
116
+ }
117
+ if (c === '\\') {
118
+ out += c;
119
+ escape = true;
120
+ continue;
121
+ }
122
+ if (c === '"') {
123
+ inString = !inString;
124
+ out += c;
125
+ continue;
126
+ }
127
+ if (inString) {
128
+ if (c === '\n') {
129
+ out += '\\n';
130
+ continue;
131
+ }
132
+ if (c === '\r') {
133
+ out += '\\r';
134
+ continue;
135
+ }
136
+ if (c === '\t') {
137
+ out += '\\t';
138
+ continue;
139
+ }
140
+ const code = c.charCodeAt(0);
141
+ if (code < 0x20) {
142
+ out += '\\u' + code.toString(16).padStart(4, '0');
143
+ continue;
144
+ }
145
+ }
146
+ out += c;
147
+ }
148
+ return out;
149
+ }
150
+ function tryParse(str) {
151
+ try {
152
+ const obj = JSON.parse(str);
153
+ if (obj &&
154
+ typeof obj.tool === 'string' &&
155
+ obj.args &&
156
+ typeof obj.args === 'object' &&
157
+ !Array.isArray(obj.args)) {
158
+ return obj;
159
+ }
160
+ return null;
161
+ }
162
+ catch {
163
+ return null;
164
+ }
165
+ }
166
+ function tryParseArray(str) {
167
+ try {
168
+ const arr = JSON.parse(str);
169
+ if (Array.isArray(arr) &&
170
+ arr.length > 0 &&
171
+ arr.every((o) => o &&
172
+ typeof o.tool === 'string' &&
173
+ o.args &&
174
+ typeof o.args === 'object' &&
175
+ !Array.isArray(o.args))) {
176
+ return arr;
177
+ }
178
+ return null;
179
+ }
180
+ catch {
181
+ return null;
182
+ }
183
+ }
184
+ function parseArgsGreedy(str) {
185
+ const result = {};
186
+ let i = str.indexOf('{') + 1;
187
+ if (i === 0)
188
+ return null;
189
+ const skipWs = () => {
190
+ while (i < str.length && /[\s,]/.test(str[i]))
191
+ i++;
192
+ };
193
+ while (i < str.length) {
194
+ skipWs();
195
+ if (i >= str.length || str[i] === '}')
196
+ break;
197
+ if (str[i] !== '"')
198
+ return null;
199
+ const keyEnd = str.indexOf('"', i + 1);
200
+ if (keyEnd === -1)
201
+ return null;
202
+ const key = str.slice(i + 1, keyEnd);
203
+ i = keyEnd + 1;
204
+ while (i < str.length && /\s/.test(str[i]))
205
+ i++;
206
+ if (str[i] !== ':')
207
+ return null;
208
+ i++;
209
+ while (i < str.length && /\s/.test(str[i]))
210
+ i++;
211
+ if (str[i] === '"') {
212
+ let lastEnd = -1;
213
+ let k = i + 1;
214
+ while (k < str.length) {
215
+ if (str.charCodeAt(k) === 92) {
216
+ k += 2;
217
+ continue;
218
+ }
219
+ if (str[k] === '"') {
220
+ let t = k + 1;
221
+ while (t < str.length && /\s/.test(str[t]))
222
+ t++;
223
+ if (t >= str.length || str[t] === ',' || str[t] === '}')
224
+ lastEnd = k;
225
+ }
226
+ k++;
227
+ }
228
+ if (lastEnd === -1)
229
+ return null;
230
+ result[key] = unescapeValue(str.slice(i + 1, lastEnd));
231
+ i = lastEnd + 1;
232
+ continue;
233
+ }
234
+ if (str[i] === '{' || str[i] === '[') {
235
+ const open = str[i];
236
+ const close = open === '{' ? '}' : ']';
237
+ const end = findMatching(str, i, open, close);
238
+ if (end === -1)
239
+ return null;
240
+ let parsed = null;
241
+ try {
242
+ parsed = JSON.parse(str.slice(i, end + 1));
243
+ }
244
+ catch {
245
+ if (open === '{')
246
+ parsed = parseArgsPermissive(str.slice(i, end + 1));
247
+ }
248
+ if (parsed === null)
249
+ return null;
250
+ result[key] = parsed;
251
+ i = end + 1;
252
+ continue;
253
+ }
254
+ let j = i;
255
+ while (j < str.length && !/[,}]/.test(str[j]))
256
+ j++;
257
+ const raw = str.slice(i, j).trim();
258
+ if (raw === 'true')
259
+ result[key] = true;
260
+ else if (raw === 'false')
261
+ result[key] = false;
262
+ else if (raw === 'null')
263
+ result[key] = null;
264
+ else {
265
+ const n = Number(raw);
266
+ result[key] = Number.isNaN(n) ? raw : n;
267
+ }
268
+ i = j;
269
+ }
270
+ return result;
271
+ }
272
+ function parseArgsPermissive(str) {
273
+ const result = {};
274
+ let i = 1;
275
+ while (i < str.length) {
276
+ while (i < str.length && /[\s,]/.test(str[i]))
277
+ i++;
278
+ if (i >= str.length || str[i] === '}')
279
+ break;
280
+ if (str[i] !== '"')
281
+ return null;
282
+ const keyEnd = str.indexOf('"', i + 1);
283
+ if (keyEnd === -1)
284
+ return null;
285
+ const key = str.slice(i + 1, keyEnd);
286
+ i = keyEnd + 1;
287
+ while (i < str.length && /\s/.test(str[i]))
288
+ i++;
289
+ if (str[i] !== ':')
290
+ return null;
291
+ i++;
292
+ while (i < str.length && /\s/.test(str[i]))
293
+ i++;
294
+ if (str[i] === '"') {
295
+ let j = i + 1;
296
+ let value = '';
297
+ while (j < str.length) {
298
+ const c = str[j];
299
+ if (c === '\\' && j + 1 < str.length) {
300
+ value += c + str[j + 1];
301
+ j += 2;
302
+ continue;
303
+ }
304
+ if (c === '"') {
305
+ let k = j + 1;
306
+ while (k < str.length && /\s/.test(str[k]))
307
+ k++;
308
+ if (k >= str.length ||
309
+ str[k] === ',' ||
310
+ str[k] === '}' ||
311
+ str[k] === ']') {
312
+ break;
313
+ }
314
+ value += '"';
315
+ j++;
316
+ continue;
317
+ }
318
+ value += c;
319
+ j++;
320
+ }
321
+ if (j >= str.length)
322
+ return null;
323
+ result[key] = unescapeValue(value);
324
+ i = j + 1;
325
+ }
326
+ else if (str[i] === '{' || str[i] === '[') {
327
+ const open = str[i];
328
+ const close = open === '{' ? '}' : ']';
329
+ const end = findMatching(str, i, open, close);
330
+ if (end === -1)
331
+ return null;
332
+ const frag = str.slice(i, end + 1);
333
+ let parsedFrag = null;
334
+ try {
335
+ parsedFrag = JSON.parse(frag);
336
+ }
337
+ catch {
338
+ if (open === '{')
339
+ parsedFrag = parseArgsPermissive(frag);
340
+ }
341
+ if (parsedFrag === null)
342
+ return null;
343
+ result[key] = parsedFrag;
344
+ i = end + 1;
345
+ }
346
+ else {
347
+ let j = i;
348
+ while (j < str.length && !/[,}]/.test(str[j]))
349
+ j++;
350
+ const raw = str.slice(i, j).trim();
351
+ if (raw === 'true')
352
+ result[key] = true;
353
+ else if (raw === 'false')
354
+ result[key] = false;
355
+ else if (raw === 'null')
356
+ result[key] = null;
357
+ else {
358
+ const n = Number(raw);
359
+ result[key] = Number.isNaN(n) ? raw : n;
360
+ }
361
+ i = j;
362
+ }
363
+ }
364
+ return result;
365
+ }
366
+ function unescapeValue(s) {
367
+ let out = '';
368
+ let i = 0;
369
+ while (i < s.length) {
370
+ const c = s[i];
371
+ if (c === '\\' && i + 1 < s.length) {
372
+ const n = s[i + 1];
373
+ if (n === 'n') {
374
+ out += '\n';
375
+ i += 2;
376
+ continue;
377
+ }
378
+ if (n === 't') {
379
+ out += '\t';
380
+ i += 2;
381
+ continue;
382
+ }
383
+ if (n === 'r') {
384
+ out += '\r';
385
+ i += 2;
386
+ continue;
387
+ }
388
+ if (n === '"') {
389
+ out += '"';
390
+ i += 2;
391
+ continue;
392
+ }
393
+ if (n === '\\') {
394
+ out += '\\';
395
+ i += 2;
396
+ continue;
397
+ }
398
+ if (n === '/') {
399
+ out += '/';
400
+ i += 2;
401
+ continue;
402
+ }
403
+ if (n === 'u' && i + 5 < s.length) {
404
+ const hex = s.slice(i + 2, i + 6);
405
+ if (/^[0-9a-fA-F]{4}$/.test(hex)) {
406
+ out += String.fromCharCode(parseInt(hex, 16));
407
+ i += 6;
408
+ continue;
409
+ }
410
+ }
411
+ out += c + n;
412
+ i += 2;
413
+ continue;
414
+ }
415
+ out += c;
416
+ i++;
417
+ }
418
+ return out;
419
+ }
420
+ function parseToolCallPermissive(text) {
421
+ const toolMatch = text.match(/"tool"\s*:\s*"([A-Za-z_][A-Za-z0-9_]*)"/);
422
+ if (!toolMatch)
423
+ return null;
424
+ const tool = toolMatch[1];
425
+ // The model occasionally puts the argument keys INLINE with "tool" —
426
+ // {"tool": "Bash", "command": "..."} — with no "args" wrapper at all.
427
+ // The strict parser rejects it (no obj.args), and the permissive parser
428
+ // used to bail out too (indexOf('"args"') === -1). Such a call was
429
+ // reported as malformed and re-asked up to MAX_MALFORMED_RETRIES times;
430
+ // after the budget ran out the run stopped with the model's text as the
431
+ // final answer. Flatten the inline keys into args when "args" is absent.
432
+ if (text.indexOf('"args"') === -1) {
433
+ const braceIdx = text.indexOf('{');
434
+ if (braceIdx === -1)
435
+ return null;
436
+ const endBrace = findMatching(text, braceIdx, '{', '}');
437
+ const objText = endBrace === -1
438
+ ? text.slice(braceIdx)
439
+ : text.slice(braceIdx, endBrace + 1);
440
+ const inlineArgs = parseArgsPermissive(objText) || parseArgsGreedy(objText);
441
+ if (!inlineArgs)
442
+ return null;
443
+ delete inlineArgs.tool;
444
+ // The value may be a string ("command": "...") or a number/bool.
445
+ return { tool, args: inlineArgs };
446
+ }
447
+ const argsIdx = text.indexOf('"args"');
448
+ if (argsIdx === -1)
449
+ return null;
450
+ const openIdx = text.indexOf('{', argsIdx);
451
+ if (openIdx === -1)
452
+ return null;
453
+ const endIdx = findMatching(text, openIdx, '{', '}');
454
+ if (tool === 'Edit') {
455
+ const balanced = endIdx === -1 ? null : text.slice(openIdx, endIdx + 1);
456
+ const tailToEnd = text.slice(openIdx);
457
+ for (const frag of [balanced, tailToEnd]) {
458
+ if (!frag)
459
+ continue;
460
+ const e = parseEditArgs(frag);
461
+ if (e)
462
+ return { tool, args: e };
463
+ }
464
+ }
465
+ const frags = [];
466
+ if (endIdx !== -1)
467
+ frags.push(text.slice(openIdx, endIdx + 1));
468
+ frags.push(text.slice(openIdx));
469
+ for (const argsStr of frags) {
470
+ const args = parseArgsPermissive(argsStr);
471
+ if (args)
472
+ return { tool, args };
473
+ const greedy = parseArgsGreedy(argsStr);
474
+ if (greedy)
475
+ return { tool, args: greedy };
476
+ }
477
+ return null;
478
+ }
479
+ function parseEditArgs(str) {
480
+ const keyRe = (name) => new RegExp('"' + name + '"\\s*:\\s*"');
481
+ const readValue = (name, nextNames) => {
482
+ const m = keyRe(name).exec(str);
483
+ if (!m)
484
+ return null;
485
+ const start = m.index + m[0].length;
486
+ let end = str.length;
487
+ for (const n of nextNames) {
488
+ const mm = keyRe(n).exec(str.slice(start));
489
+ if (mm) {
490
+ const absIdx = start + mm.index;
491
+ if (absIdx < end)
492
+ end = absIdx;
493
+ }
494
+ }
495
+ let val = str.slice(start, end);
496
+ val = val.replace(/"\s*[,}]?\s*$/, '');
497
+ return val;
498
+ };
499
+ const path = readValue('path', ['old_string', 'new_string']);
500
+ const oldStr = readValue('old_string', ['new_string']);
501
+ const newStr = readValue('new_string', []);
502
+ if (path === null || oldStr === null || newStr === null)
503
+ return null;
504
+ return { path: path, old_string: oldStr, new_string: newStr };
505
+ }
506
+ function extractJsonObjects(text) {
507
+ const objects = [];
508
+ let i = 0;
509
+ while (i < text.length) {
510
+ if (text[i] === '{') {
511
+ const end = findMatching(text, i, '{', '}');
512
+ if (end !== -1) {
513
+ objects.push(text.slice(i, end + 1));
514
+ i = end + 1;
515
+ continue;
516
+ }
517
+ }
518
+ if (text[i] === '[') {
519
+ const end = findMatching(text, i, '[', ']');
520
+ if (end !== -1) {
521
+ objects.push(text.slice(i, end + 1));
522
+ i = end + 1;
523
+ continue;
524
+ }
525
+ }
526
+ i++;
527
+ }
528
+ return objects;
529
+ }
530
+ function findMatching(text, openIdx, openCh, closeCh) {
531
+ let depth = 0;
532
+ let inString = false;
533
+ let escape = false;
534
+ for (let i = openIdx; i < text.length; i++) {
535
+ const c = text[i];
536
+ if (escape) {
537
+ escape = false;
538
+ continue;
539
+ }
540
+ if (c === '\\') {
541
+ escape = true;
542
+ continue;
543
+ }
544
+ if (c === '"') {
545
+ inString = !inString;
546
+ continue;
547
+ }
548
+ if (inString)
549
+ continue;
550
+ if (c === openCh)
551
+ depth++;
552
+ else if (c === closeCh) {
553
+ depth--;
554
+ if (depth === 0)
555
+ return i;
556
+ }
557
+ }
558
+ return -1;
559
+ }
560
+ // The model sometimes returns a tool call with single-quoted keys/strings
561
+ // ("{'tool': 'Read', 'args': {...}}") or unquoted keys
562
+ // ("{tool: \"Read\", args: {...}}"). This is not valid JSON, and without
563
+ // normalization such an answer is silently taken as final — the agent stalls
564
+ // without calling a tool. We normalize it to double quotes.
565
+ function normalizePseudoJson(str) {
566
+ // Unquoted keys: {tool: ...} or , args: ... → "tool": / "args":
567
+ let out = str.replace(/([\{\[]\s*)([A-Za-z_][A-Za-z0-9_]*)\s*:/g, '$1"$2":');
568
+ out = out.replace(/,\s*([A-Za-z_][A-Za-z0-9_]*)\s*:/g, ', "$1":');
569
+ // Single quotes → double quotes. We don't touch the content of already
570
+ // double-quoted strings in a row, and escape stray double quotes inside
571
+ // single quotes.
572
+ let res = '';
573
+ let inDouble = false;
574
+ let inSingle = false;
575
+ for (let i = 0; i < out.length; i++) {
576
+ const c = out[i];
577
+ if (c === '\\' && (inDouble || inSingle)) {
578
+ res += c;
579
+ if (i + 1 < out.length) {
580
+ res += out[i + 1];
581
+ i++;
582
+ }
583
+ continue;
584
+ }
585
+ if (c === '"' && !inSingle) {
586
+ inDouble = !inDouble;
587
+ res += c;
588
+ continue;
589
+ }
590
+ if (c === "'" && !inDouble) {
591
+ if (!inSingle) {
592
+ inSingle = true;
593
+ res += '"';
594
+ }
595
+ else {
596
+ inSingle = false;
597
+ res += '"';
598
+ }
599
+ continue;
600
+ }
601
+ if (inSingle && c === '"') {
602
+ res += '\\"';
603
+ continue;
604
+ }
605
+ res += c;
606
+ }
607
+ return res;
608
+ }
609
+ // The model sometimes corrupts the head of a call: `<|tool": "Bash", "args": {...}`,
610
+ // `tool": "Read", ...`, `**tool**: ...`, `- tool: ...`. Such answers have no
611
+ // opening `{`, and the `tool` key lost its first quote. If such an answer is
612
+ // taken as final, the agent silently stalls (a frequent "stop").
613
+ // We repair it: trim the junk prefix up to the word tool, add `{` and
614
+ // balance the key quotes.
615
+ function repairToolCallPreamble(text) {
616
+ const t = (text || '').trim();
617
+ const m = t.match(/(?:^|[^A-Za-z0-9_])(?:\*\*)?(tool)(?:\*\*)?["'`\u2018\u2019\u201c\u201d]*\s*:/);
618
+ if (!m || m.index === undefined)
619
+ return null;
620
+ // We look for the start from the first quote/bracket around the key, otherwise from the word tool.
621
+ let start = m.index;
622
+ const brace = t.indexOf('{', Math.max(0, start - 1));
623
+ if (brace !== -1 && brace < start)
624
+ start = brace;
625
+ let frag = t.slice(start);
626
+ // If the fragment does not start with `{` — we add it.
627
+ if (!frag.startsWith('{')) {
628
+ // The key may have lost its opening quote: tool": → "tool".
629
+ // We trim the leading junk up to the word tool and normalize the key quotes.
630
+ frag = frag.replace(/^[^A-Za-z0-9_]*/, '');
631
+ frag = frag.replace(/^(?:\*\*)?(["'`\u2018\u2019\u201c\u201d]*)(tool)(?:\*\*)?["'`\u2018\u2019\u201c\u201d]*\s*:/, '"$2":');
632
+ frag = '{' + frag;
633
+ }
634
+ return frag;
635
+ }
636
+ export function parseToolCall(text) {
637
+ if (!text || typeof text !== 'string')
638
+ return null;
639
+ let cleaned = text.trim();
640
+ cleaned = cleaned
641
+ .replace(/^```(?:json)?\s*/i, '')
642
+ .replace(/```$/i, '')
643
+ .trim();
644
+ const candidates = extractJsonObjects(cleaned);
645
+ // We collect EVERY recognized call, not just the first one found. The model
646
+ // often emits several separate {"tool": ...} objects in one answer instead of
647
+ // a single JSON array. Returning only one of them used to drop the rest and
648
+ // could leave the agent "stalled after a tool call" with pending work.
649
+ const collected = [];
650
+ const tryCollect = (parsed) => {
651
+ if (!parsed)
652
+ return false;
653
+ if (Array.isArray(parsed))
654
+ collected.push(...parsed);
655
+ else
656
+ collected.push(parsed);
657
+ return true;
658
+ };
659
+ for (let i = 0; i < candidates.length; i++) {
660
+ const raw = candidates[i];
661
+ // A JSON array of calls is authoritative: if present, use all of it.
662
+ const arrFirst = tryParseArray(raw);
663
+ if (arrFirst)
664
+ return arrFirst;
665
+ const arrRepaired = tryParseArray(raw.replace(/\\(?!["\\/bfnrtu])/g, '\\\\'));
666
+ if (arrRepaired)
667
+ return arrRepaired;
668
+ const first = tryParse(raw);
669
+ if (first) {
670
+ tryCollect(first);
671
+ continue;
672
+ }
673
+ const repaired = raw.replace(/\\(?!["\\/bfnrtu])/g, '\\\\');
674
+ const second = tryParse(repaired);
675
+ if (second) {
676
+ tryCollect(second);
677
+ continue;
678
+ }
679
+ const ctrl = repairRawControlChars(raw);
680
+ const third = tryParse(ctrl);
681
+ if (third) {
682
+ tryCollect(third);
683
+ continue;
684
+ }
685
+ const ctrlArr = tryParseArray(ctrl);
686
+ if (ctrlArr)
687
+ return ctrlArr;
688
+ }
689
+ if (collected.length === 1)
690
+ return collected[0];
691
+ if (collected.length > 1)
692
+ return collected;
693
+ // Pseudo-JSON (single quotes / unquoted keys) — normalize and try to parse
694
+ // as a regular call before going permissive.
695
+ if (/['"]?tool['"]?\s*:/.test(cleaned)) {
696
+ const norm = normalizePseudoJson(cleaned);
697
+ if (norm !== cleaned) {
698
+ for (const raw of extractJsonObjects(norm)) {
699
+ const a = tryParseArray(raw);
700
+ if (a)
701
+ return a;
702
+ const o = tryParse(raw);
703
+ if (o)
704
+ return o;
705
+ }
706
+ }
707
+ }
708
+ const permissive = parseToolCallPermissive(cleaned) ||
709
+ parseToolCallPermissive(repairRawControlChars(cleaned)) ||
710
+ parseToolCallPermissive(normalizePseudoJson(cleaned));
711
+ if (permissive)
712
+ return { ...permissive, _permissive: true };
713
+ const toolIdx = cleaned.search(/["']?tool["']?\s:/);
714
+ if (toolIdx > 0) {
715
+ let tail = cleaned.slice(toolIdx);
716
+ tail = tail.replace(/<[^>]*>.*$/s, '').trim();
717
+ const tailPermissive = parseToolCallPermissive('{"' + tail) ||
718
+ parseToolCallPermissive('{"' + repairRawControlChars(tail));
719
+ if (tailPermissive)
720
+ return { ...tailPermissive, _permissive: true };
721
+ }
722
+ const xmlCalls = parseXmlToolCalls(cleaned);
723
+ if (xmlCalls)
724
+ return Array.isArray(xmlCalls) ? xmlCalls : [xmlCalls];
725
+ // Last attempt: "fix" a corrupted call head (`<|tool": ...`,
726
+ // `tool": ...`, `**tool**: ...`, `- tool: ...`). We do this ONLY as a
727
+ // fallback, after regular parsing — otherwise it's easy to corrupt valid
728
+ // JSON (e.g. an array of calls starts with `[`, containing `{"tool":`).
729
+ const preamble = repairToolCallPreamble(cleaned);
730
+ if (preamble && preamble !== cleaned) {
731
+ const reps = [
732
+ preamble,
733
+ repairRawControlChars(preamble),
734
+ normalizePseudoJson(preamble),
735
+ ];
736
+ for (const rep of reps) {
737
+ for (const raw of extractJsonObjects(rep)) {
738
+ const a = tryParseArray(raw);
739
+ if (a)
740
+ return a;
741
+ const o = tryParse(raw);
742
+ if (o)
743
+ return o;
744
+ }
745
+ }
746
+ const perm = parseToolCallPermissive(preamble);
747
+ if (perm)
748
+ return { ...perm, _permissive: true };
749
+ }
750
+ return null;
751
+ }
752
+ export function extractPreToolText(text) {
753
+ if (!text)
754
+ return '';
755
+ const patterns = ['{"tool"', '[{"tool"', '{"tool":', '[{"tool":'];
756
+ let earliest = -1;
757
+ for (const p of patterns) {
758
+ const idx = text.indexOf(p);
759
+ if (idx >= 0 && (earliest === -1 || idx < earliest))
760
+ earliest = idx;
761
+ }
762
+ if (earliest === -1)
763
+ return '';
764
+ return text.slice(0, earliest).trim();
765
+ }