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.
- package/CHANGELOG.md +32 -0
- package/README.md +8 -0
- package/dist/agent-loop.js +258 -857
- package/dist/answer-parse.js +765 -0
- package/dist/browser-chats.js +161 -0
- package/dist/browser-pure.js +238 -0
- package/dist/browser.js +45 -390
- package/dist/changelog.js +29 -0
- package/dist/cli-args.js +48 -0
- package/dist/compact.js +9 -0
- package/dist/config.js +24 -0
- package/dist/diagnostics.js +71 -0
- package/dist/hooks.js +30 -2
- package/dist/i18n/common.js +4 -0
- package/dist/i18n/config.js +8 -0
- package/dist/index.js +81 -24
- package/dist/input.js +25 -1
- package/dist/run-task.js +33 -1
- package/dist/shell.js +102 -1
- package/dist/statusline.js +52 -0
- package/dist/subagent.js +156 -0
- package/dist/system-prompt.js +38 -1
- package/dist/tools.js +105 -9
- package/package.json +10 -8
- package/scripts/changelog-draft.mts +38 -1
- package/scripts/{eslint-ts6.mjs → eslint-ts6.mts} +11 -4
- package/scripts/release.mts +36 -7
- package/scripts/{render-demo.mjs → render-demo.mts} +12 -11
- package/scripts/{self-smoke.mjs → self-smoke.mts} +86 -5
- /package/scripts/{clean-dist.mjs → clean-dist.mts} +0 -0
- /package/scripts/{coverage-gate.mjs → coverage-gate.mts} +0 -0
|
@@ -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
|
+
}
|