zames_pro 2.6.0 → 2.8.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/README.md +21 -0
- package/dist/agent-loop.js +141 -83
- package/dist/attachments.js +267 -0
- package/dist/browser.js +139 -50
- package/dist/config-menu.js +25 -25
- package/dist/config.js +5 -5
- package/dist/gitTools.js +4 -4
- package/dist/i18n.js +31 -7
- package/dist/index.js +295 -168
- package/dist/input.js +237 -83
- package/dist/markdown.js +1 -1
- package/dist/net-capture.js +20 -20
- package/dist/self-review.js +16 -16
- package/dist/sessions.js +11 -11
- package/dist/spinner.js +20 -16
- package/dist/system-prompt.js +29 -2
- package/dist/theme.js +26 -26
- package/dist/tools.js +9 -9
- package/dist/transcript.js +2 -2
- package/dist/types.js +2 -2
- package/dist/web.js +16 -16
- package/dist/xml-toolcall.js +89 -14
- package/package.json +1 -1
- package/scripts/postinstall.mjs +78 -51
package/README.md
CHANGED
|
@@ -41,6 +41,27 @@ While the agent is working you can keep typing: press Enter to queue a message
|
|
|
41
41
|
abort the current generation. This mirrors typing during generation on the
|
|
42
42
|
DeepSeek website.
|
|
43
43
|
|
|
44
|
+
### Images and files
|
|
45
|
+
|
|
46
|
+
You can paste an image or a file into the input line (Ctrl+Shift+V / Shift+Insert
|
|
47
|
+
or the terminal's own paste). zames saves it under `<project>/tmp` and shows a
|
|
48
|
+
marker in the line - `[image#1]` for images, `[file#1]` for other files - then
|
|
49
|
+
uploads the real file to the chat together with your message. These are the
|
|
50
|
+
same formats the DeepSeek web chat accepts (PNG, JPEG, GIF, WEBP, BMP, SVG and
|
|
51
|
+
the usual document types).
|
|
52
|
+
|
|
53
|
+
Pasting an image works when the terminal sends it as a `data:` URL or as a
|
|
54
|
+
base64 blob with a recognizable image signature. You can also paste a path to a
|
|
55
|
+
local file (drag a file into the terminal or copy its path); if the file exists
|
|
56
|
+
it is attached, otherwise the text is inserted as usual.
|
|
57
|
+
|
|
58
|
+
Press Ctrl+V (or use an empty paste) and zames reads the image straight from
|
|
59
|
+
the clipboard: Linux via wl-paste (Wayland) or xclip/xsel (X11), macOS
|
|
60
|
+
via pngpaste, Windows via PowerShell. The needed Linux tools are installed
|
|
61
|
+
automatically on npm install (best-effort, through the detected package
|
|
62
|
+
manager). If no image is found, zames says so and reports which tool it tried,
|
|
63
|
+
instead of staying silent.
|
|
64
|
+
|
|
44
65
|
### Options
|
|
45
66
|
|
|
46
67
|
```
|
package/dist/agent-loop.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { buildSystemPrompt } from './system-prompt.js';
|
|
2
2
|
import { getGitContext, formatGitContext } from './gitTools.js';
|
|
3
3
|
import { parseXmlToolCalls } from './xml-toolcall.js';
|
|
4
|
-
|
|
4
|
+
import { translate } from './i18n.js';
|
|
5
|
+
export async function runAgentLoop({ browser, tools, task, workdir, maxIterations = 40, freshChat = false, sendSystemPrompt = false, transcript = null, attachments = [], onThinking = () => { }, onAssistantThought = () => { }, onToolCall = () => { }, onToolResult = () => { }, onAssistantMessage = () => { }, onChatReady = () => { }, onWarning = () => { }, debugLog = false, locale = 'ru', }) {
|
|
5
6
|
if (freshChat) {
|
|
6
7
|
await browser.newChat();
|
|
7
8
|
transcript?.log('new_chat');
|
|
8
9
|
}
|
|
9
|
-
//
|
|
10
|
+
// Report the current chat id to the caller.
|
|
10
11
|
let lastReportedChatId = null;
|
|
11
12
|
const reportChat = async () => {
|
|
12
13
|
let id = null;
|
|
@@ -42,33 +43,56 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
42
43
|
gitContext: gitText,
|
|
43
44
|
});
|
|
44
45
|
onThinking();
|
|
45
|
-
// system-prompt
|
|
46
|
+
// system-prompt is an agent send: throttled (agent: true).
|
|
46
47
|
await browser.ask(systemPrompt, { timeout: 60_000, agent: true });
|
|
47
48
|
await reportChat();
|
|
48
49
|
}
|
|
49
50
|
let message = task;
|
|
51
|
+
if (attachments.length) {
|
|
52
|
+
// Tell the model what the markers mean even when the system-prompt is not
|
|
53
|
+
// (re)sent (resumed chat). The files are uploaded to the chat by the
|
|
54
|
+
// browser; this note just explains the [image#N] / [file#N] markers.
|
|
55
|
+
const markers = attachments.map((a, i) => {
|
|
56
|
+
const ext = String(a.name || '');
|
|
57
|
+
const isImg = /\.(png|jpe?g|gif|webp|bmp|svg|ico)$/i.test(ext);
|
|
58
|
+
return (isImg ? '[image#' : '[file#') + (i + 1) + ']';
|
|
59
|
+
});
|
|
60
|
+
message =
|
|
61
|
+
task +
|
|
62
|
+
String.fromCharCode(10) +
|
|
63
|
+
String.fromCharCode(10) +
|
|
64
|
+
'(The user attached ' +
|
|
65
|
+
attachments.length +
|
|
66
|
+
' file(s) to this message: ' +
|
|
67
|
+
markers.join(', ') +
|
|
68
|
+
'. Look at the uploaded images in the chat; file copies are in <project>/tmp.)';
|
|
69
|
+
}
|
|
50
70
|
transcript?.log('task', { task });
|
|
51
71
|
let malformedRetries = 0;
|
|
52
72
|
const MAX_MALFORMED_RETRIES = 3;
|
|
53
73
|
let stallRetries = 0;
|
|
54
74
|
const MAX_STALL_RETRIES = 5;
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
75
|
+
// Guard against "the agent stalled": DeepSeek sometimes sends a final text
|
|
76
|
+
// that merely DESCRIBES the next tool call (or cuts the answer off
|
|
77
|
+
// mid-word), and the agent silently finishes the task even though the work
|
|
78
|
+
// is not done. If the final answer looks like "I'll call ... now" — we
|
|
79
|
+
// re-ask instead of stopping. The counter is shared so we don't loop on a
|
|
80
|
+
// chatty model.
|
|
60
81
|
let looksDoneRetries = 0;
|
|
61
82
|
const MAX_LOOKSDONE_RETRIES = 3;
|
|
62
83
|
for (let i = 0; i < maxIterations; i++) {
|
|
63
84
|
onThinking();
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
85
|
+
// The first message (task) is user input: no throttle.
|
|
86
|
+
// Subsequent ones (tool-result and resend requests) are agent sends:
|
|
87
|
+
// throttled so we don't hit the rate limit.
|
|
67
88
|
const isFirst = i === 0;
|
|
68
|
-
const rawResponse = await browser.ask(message, {
|
|
89
|
+
const rawResponse = await browser.ask(message, {
|
|
90
|
+
agent: !isFirst,
|
|
91
|
+
attachments: isFirst ? attachments : [],
|
|
92
|
+
});
|
|
69
93
|
await reportChat();
|
|
70
94
|
transcript?.log('assistant_raw', { response: rawResponse });
|
|
71
|
-
//
|
|
95
|
+
// The user aborted generation (Esc/Ctrl+C).
|
|
72
96
|
if (/^\s*\(прервано пользователем\)\s*$/.test(rawResponse)) {
|
|
73
97
|
transcript?.log('user_aborted');
|
|
74
98
|
return rawResponse;
|
|
@@ -87,10 +111,10 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
87
111
|
}
|
|
88
112
|
}
|
|
89
113
|
if (!parsed) {
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
114
|
+
// The answer looks like a (possibly truncated) tool call. We catch not
|
|
115
|
+
// only explicit JSON but also XML/DSML forms, "dirty" variants and
|
|
116
|
+
// unclosed fragments: if such an answer is silently taken as final, the
|
|
117
|
+
// agent stalls even though the model tried to call a tool.
|
|
94
118
|
const looksLikeToolCall = responseLooksLikeToolCall(rawResponse);
|
|
95
119
|
if (looksLikeToolCall && malformedRetries < MAX_MALFORMED_RETRIES) {
|
|
96
120
|
malformedRetries++;
|
|
@@ -113,11 +137,11 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
113
137
|
continue;
|
|
114
138
|
}
|
|
115
139
|
const trimmed = (rawResponse || '').trim();
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
140
|
+
// A service answer is a SHORT DeepSeek placeholder ("Reading…") or a
|
|
141
|
+
// short rate-limit notice. Words about the rate limit in a LONG answer
|
|
142
|
+
// are usually the agent itself quoting code/logs (the transcript had
|
|
143
|
+
// exactly such a case: a 1365-char answer about ask() and limits), and
|
|
144
|
+
// it must not be taken as "service", otherwise the agent re-asks in vain.
|
|
121
145
|
const looksService = !trimmed ||
|
|
122
146
|
trimmed.length < 2 ||
|
|
123
147
|
/^(reading|thinking|searching|analyzing|generating|stop|остановить|читаю|думаю|поиск|анализ)[\s.…]*$/i.test(trimmed) ||
|
|
@@ -142,11 +166,11 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
142
166
|
'Если задача выполнена — вызови инструмент respond с итоговым сообщением.';
|
|
143
167
|
continue;
|
|
144
168
|
}
|
|
145
|
-
//
|
|
146
|
-
// DeepSeek
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
169
|
+
// The answer looks like "I'll call a tool now", but contains no call.
|
|
170
|
+
// DeepSeek sometimes cuts the turn like this: writes "Now update
|
|
171
|
+
// README…" or "Let me run the tests…" and goes silent. If this is taken
|
|
172
|
+
// as final, the agent stalls without doing the work. We ask it to
|
|
173
|
+
// continue and to actually call a tool this time (or respond if truly done).
|
|
150
174
|
if (looksLikeUnfinishedWork(trimmed) && looksDoneRetries < MAX_LOOKSDONE_RETRIES) {
|
|
151
175
|
looksDoneRetries++;
|
|
152
176
|
transcript?.log('unfinished_retry', {
|
|
@@ -168,6 +192,14 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
168
192
|
'сообщением оператору.';
|
|
169
193
|
continue;
|
|
170
194
|
}
|
|
195
|
+
// All re-ask attempts are exhausted, yet the answer still looks like a
|
|
196
|
+
// tool call. Most likely this is a silent stall: we show the operator a
|
|
197
|
+
// warning in the terminal (not only in the transcript) so they see the
|
|
198
|
+
// problem immediately instead of wondering why the agent stalled.
|
|
199
|
+
if (responseLooksLikeToolCall(rawResponse)) {
|
|
200
|
+
transcript?.log('suspicious_final', { response: rawResponse });
|
|
201
|
+
onWarning(translate(locale)('msg.suspicious_stop'));
|
|
202
|
+
}
|
|
171
203
|
onAssistantMessage(rawResponse);
|
|
172
204
|
transcript?.log('assistant_final', { message: rawResponse });
|
|
173
205
|
return rawResponse;
|
|
@@ -178,9 +210,9 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
178
210
|
const msg = typeof respondCall.args.message === 'string'
|
|
179
211
|
? respondCall.args.message
|
|
180
212
|
: String(respondCall.args.message ?? '');
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
213
|
+
// An empty respond is not final: the model called respond but wrote no
|
|
214
|
+
// summary. Finishing like this would show the operator nothing and the
|
|
215
|
+
// task would "hang". We ask it to continue (within stallRetries).
|
|
184
216
|
if (!msg.trim() && stallRetries < MAX_STALL_RETRIES) {
|
|
185
217
|
stallRetries++;
|
|
186
218
|
transcript?.log('empty_respond', { attempt: stallRetries });
|
|
@@ -243,45 +275,45 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
243
275
|
}
|
|
244
276
|
return 'Достигнут лимит итераций.';
|
|
245
277
|
}
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
// (`<|tool": ...`, `**tool**:`, `tool": ...`),
|
|
278
|
+
// The answer looks like a tool call, but parseToolCall() did not recognize it.
|
|
279
|
+
// Used as a safeguard against "the agent called a tool and stopped": in that
|
|
280
|
+
// case runAgentLoop asks the model to resend the call instead of finishing the
|
|
281
|
+
// task. We catch both explicit formats and "broken" call heads
|
|
282
|
+
// (`<|tool": ...`, `**tool**:`, `tool": ...`), and truncated calls.
|
|
251
283
|
export function responseLooksLikeToolCall(rawResponse) {
|
|
252
284
|
const raw = rawResponse || '';
|
|
253
285
|
return (
|
|
254
|
-
//
|
|
255
|
-
// function_call
|
|
286
|
+
// Explicit tool-call format markers: the JSON key "tool", XML/DSML tags,
|
|
287
|
+
// function_call, etc.
|
|
256
288
|
/("tool"\s*:|\btool_calls?\b|\binvoke\b|\bparameter\b|DSML|function_call)/i.test(raw) ||
|
|
257
|
-
//
|
|
258
|
-
// (`<|tool":`, `**tool**:`, `- tool:`):
|
|
289
|
+
// "tool" without an opening quote/bracket, with a junk prefix
|
|
290
|
+
// (`<|tool":`, `**tool**:`, `- tool:`): a call key, not prose.
|
|
259
291
|
/(^|[^A-Za-z0-9_])(?:\*\*)?tool(?:\*\*)?["'`\u2018\u2019\u201c\u201d]*\s*:/.test(raw) ||
|
|
260
|
-
//
|
|
292
|
+
// Keys in single quotes or unquoted: {'tool': 'Read', ...}.
|
|
261
293
|
/[\{\[]\s*['"]?(tool|name|args)['"]?\s*:/.test(raw) ||
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
//
|
|
294
|
+
// Truncated call: starts like a JSON object but is not closed, and has an
|
|
295
|
+
// argument key (args/command/path/...). We require the opening bracket at
|
|
296
|
+
// the start (after spaces/prefix) so we don't catch ordinary prose with
|
|
297
|
+
// colons like "path: ...".
|
|
266
298
|
/^\s*[\[\{]/.test(raw) &&
|
|
267
299
|
/["']?(?:tool|args|command|path|old_string|content|content_base64)["']?\s*:/.test(raw) ||
|
|
268
300
|
/<\s*\|?\s*(DSML|invoke|parameter)/i.test(raw) ||
|
|
269
301
|
/^\s*\[?\s*\{[^}]*$/.test(raw.trim()));
|
|
270
302
|
}
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
//
|
|
303
|
+
// Text that promises a tool call in the future tense but contains no call
|
|
304
|
+
// itself. DeepSeek regularly "hangs" like this: it writes
|
|
305
|
+
// "Now update README to mention …", "Let me run the tests", "I'll check now"
|
|
306
|
+
// and stops. Such answers must not be taken as final — otherwise the agent
|
|
307
|
+
// stalls without doing the work. We keep the heuristic narrow (future tense /
|
|
308
|
+
// intent) so we don't catch ordinary reports of completed work.
|
|
277
309
|
function looksLikeUnfinishedWork(text) {
|
|
278
310
|
const t = (text || '').trim();
|
|
279
311
|
if (!t)
|
|
280
312
|
return false;
|
|
281
|
-
//
|
|
313
|
+
// Long answers (reports) are left alone — anything can be in there.
|
|
282
314
|
if (t.length > 600)
|
|
283
315
|
return false;
|
|
284
|
-
//
|
|
316
|
+
// A final marker is already present — treat the answer as complete.
|
|
285
317
|
if (/\b(done|finished|completed|готово|выполнено|завершено)\b/i.test(t)) {
|
|
286
318
|
return false;
|
|
287
319
|
}
|
|
@@ -722,17 +754,18 @@ function findMatching(text, openIdx, openCh, closeCh) {
|
|
|
722
754
|
}
|
|
723
755
|
return -1;
|
|
724
756
|
}
|
|
725
|
-
//
|
|
726
|
-
//
|
|
727
|
-
// (
|
|
728
|
-
//
|
|
729
|
-
//
|
|
757
|
+
// The model sometimes returns a tool call with single-quoted keys/strings
|
|
758
|
+
// ("{'tool': 'Read', 'args': {...}}") or unquoted keys
|
|
759
|
+
// ("{tool: \"Read\", args: {...}}"). This is not valid JSON, and without
|
|
760
|
+
// normalization such an answer is silently taken as final — the agent stalls
|
|
761
|
+
// without calling a tool. We normalize it to double quotes.
|
|
730
762
|
function normalizePseudoJson(str) {
|
|
731
|
-
//
|
|
763
|
+
// Unquoted keys: {tool: ...} or , args: ... → "tool": / "args":
|
|
732
764
|
let out = str.replace(/([\{\[]\s*)([A-Za-z_][A-Za-z0-9_]*)\s*:/g, '$1"$2":');
|
|
733
765
|
out = out.replace(/,\s*([A-Za-z_][A-Za-z0-9_]*)\s*:/g, ', "$1":');
|
|
734
|
-
//
|
|
735
|
-
//
|
|
766
|
+
// Single quotes → double quotes. We don't touch the content of already
|
|
767
|
+
// double-quoted strings in a row, and escape stray double quotes inside
|
|
768
|
+
// single quotes.
|
|
736
769
|
let res = '';
|
|
737
770
|
let inDouble = false;
|
|
738
771
|
let inSingle = false;
|
|
@@ -770,27 +803,27 @@ function normalizePseudoJson(str) {
|
|
|
770
803
|
}
|
|
771
804
|
return res;
|
|
772
805
|
}
|
|
773
|
-
//
|
|
774
|
-
// `tool": "Read", ...`, `**tool**: ...`, `- tool: ...`.
|
|
775
|
-
//
|
|
776
|
-
//
|
|
777
|
-
//
|
|
778
|
-
//
|
|
806
|
+
// The model sometimes corrupts the head of a call: `<|tool": "Bash", "args": {...}`,
|
|
807
|
+
// `tool": "Read", ...`, `**tool**: ...`, `- tool: ...`. Such answers have no
|
|
808
|
+
// opening `{`, and the `tool` key lost its first quote. If such an answer is
|
|
809
|
+
// taken as final, the agent silently stalls (a frequent "stop").
|
|
810
|
+
// We repair it: trim the junk prefix up to the word tool, add `{` and
|
|
811
|
+
// balance the key quotes.
|
|
779
812
|
function repairToolCallPreamble(text) {
|
|
780
813
|
const t = (text || '').trim();
|
|
781
814
|
const m = t.match(/(?:^|[^A-Za-z0-9_])(?:\*\*)?(tool)(?:\*\*)?["'`\u2018\u2019\u201c\u201d]*\s*:/);
|
|
782
815
|
if (!m || m.index === undefined)
|
|
783
816
|
return null;
|
|
784
|
-
//
|
|
817
|
+
// We look for the start from the first quote/bracket around the key, otherwise from the word tool.
|
|
785
818
|
let start = m.index;
|
|
786
819
|
const brace = t.indexOf('{', Math.max(0, start - 1));
|
|
787
820
|
if (brace !== -1 && brace < start)
|
|
788
821
|
start = brace;
|
|
789
822
|
let frag = t.slice(start);
|
|
790
|
-
//
|
|
823
|
+
// If the fragment does not start with `{` — we add it.
|
|
791
824
|
if (!frag.startsWith('{')) {
|
|
792
|
-
//
|
|
793
|
-
//
|
|
825
|
+
// The key may have lost its opening quote: tool": → "tool".
|
|
826
|
+
// We trim the leading junk up to the word tool and normalize the key quotes.
|
|
794
827
|
frag = frag.replace(/^[^A-Za-z0-9_]*/, '');
|
|
795
828
|
frag = frag.replace(/^(?:\*\*)?(["'`\u2018\u2019\u201c\u201d]*)(tool)(?:\*\*)?["'`\u2018\u2019\u201c\u201d]*\s*:/, '"$2":');
|
|
796
829
|
frag = '{' + frag;
|
|
@@ -806,8 +839,23 @@ export function parseToolCall(text) {
|
|
|
806
839
|
.replace(/```$/i, '')
|
|
807
840
|
.trim();
|
|
808
841
|
const candidates = extractJsonObjects(cleaned);
|
|
809
|
-
|
|
842
|
+
// We collect EVERY recognized call, not just the first one found. The model
|
|
843
|
+
// often emits several separate {"tool": ...} objects in one answer instead of
|
|
844
|
+
// a single JSON array. Returning only one of them used to drop the rest and
|
|
845
|
+
// could leave the agent "stalled after a tool call" with pending work.
|
|
846
|
+
const collected = [];
|
|
847
|
+
const tryCollect = (parsed) => {
|
|
848
|
+
if (!parsed)
|
|
849
|
+
return false;
|
|
850
|
+
if (Array.isArray(parsed))
|
|
851
|
+
collected.push(...parsed);
|
|
852
|
+
else
|
|
853
|
+
collected.push(parsed);
|
|
854
|
+
return true;
|
|
855
|
+
};
|
|
856
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
810
857
|
const raw = candidates[i];
|
|
858
|
+
// A JSON array of calls is authoritative: if present, use all of it.
|
|
811
859
|
const arrFirst = tryParseArray(raw);
|
|
812
860
|
if (arrFirst)
|
|
813
861
|
return arrFirst;
|
|
@@ -815,22 +863,32 @@ export function parseToolCall(text) {
|
|
|
815
863
|
if (arrRepaired)
|
|
816
864
|
return arrRepaired;
|
|
817
865
|
const first = tryParse(raw);
|
|
818
|
-
if (first)
|
|
819
|
-
|
|
866
|
+
if (first) {
|
|
867
|
+
tryCollect(first);
|
|
868
|
+
continue;
|
|
869
|
+
}
|
|
820
870
|
const repaired = raw.replace(/\\(?!["\\/bfnrtu])/g, '\\\\');
|
|
821
871
|
const second = tryParse(repaired);
|
|
822
|
-
if (second)
|
|
823
|
-
|
|
872
|
+
if (second) {
|
|
873
|
+
tryCollect(second);
|
|
874
|
+
continue;
|
|
875
|
+
}
|
|
824
876
|
const ctrl = repairRawControlChars(raw);
|
|
825
877
|
const third = tryParse(ctrl);
|
|
826
|
-
if (third)
|
|
827
|
-
|
|
878
|
+
if (third) {
|
|
879
|
+
tryCollect(third);
|
|
880
|
+
continue;
|
|
881
|
+
}
|
|
828
882
|
const ctrlArr = tryParseArray(ctrl);
|
|
829
883
|
if (ctrlArr)
|
|
830
884
|
return ctrlArr;
|
|
831
885
|
}
|
|
832
|
-
|
|
833
|
-
|
|
886
|
+
if (collected.length === 1)
|
|
887
|
+
return collected[0];
|
|
888
|
+
if (collected.length > 1)
|
|
889
|
+
return collected;
|
|
890
|
+
// Pseudo-JSON (single quotes / unquoted keys) — normalize and try to parse
|
|
891
|
+
// as a regular call before going permissive.
|
|
834
892
|
if (/['"]?tool['"]?\s*:/.test(cleaned)) {
|
|
835
893
|
const norm = normalizePseudoJson(cleaned);
|
|
836
894
|
if (norm !== cleaned) {
|
|
@@ -861,10 +919,10 @@ export function parseToolCall(text) {
|
|
|
861
919
|
const xmlCalls = parseXmlToolCalls(cleaned);
|
|
862
920
|
if (xmlCalls)
|
|
863
921
|
return Array.isArray(xmlCalls) ? xmlCalls : [xmlCalls];
|
|
864
|
-
//
|
|
865
|
-
// `tool": ...`, `**tool**: ...`, `- tool: ...`).
|
|
866
|
-
// fallback,
|
|
867
|
-
// (
|
|
922
|
+
// Last attempt: "fix" a corrupted call head (`<|tool": ...`,
|
|
923
|
+
// `tool": ...`, `**tool**: ...`, `- tool: ...`). We do this ONLY as a
|
|
924
|
+
// fallback, after regular parsing — otherwise it's easy to corrupt valid
|
|
925
|
+
// JSON (e.g. an array of calls starts with `[`, containing `{"tool":`).
|
|
868
926
|
const preamble = repairToolCallPreamble(cleaned);
|
|
869
927
|
if (preamble && preamble !== cleaned) {
|
|
870
928
|
const reps = [
|