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 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
  ```
@@ -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
- export async function runAgentLoop({ browser, tools, task, workdir, maxIterations = 40, freshChat = false, sendSystemPrompt = false, transcript = null, onThinking = () => { }, onAssistantThought = () => { }, onToolCall = () => { }, onToolResult = () => { }, onAssistantMessage = () => { }, onChatReady = () => { }, debugLog = false, locale = 'ru', }) {
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
- // Сообщаем вызывающему актуальный chat id.
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 — отправка агента: с паузой (agent: true).
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
- // Защита от «агент встал»: DeepSeek иногда присылает финальный текст,
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
- // Первое сообщение (task) — пользовательский ввод: без паузы.
65
- // Последующие (tool-result и просьбы переотправить) — агентские:
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, { agent: !isFirst });
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
- // Пользователь прервал генерацию (Esc/Ctrl+C).
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
- // только явный JSON, но и XML/DSML-формы, «грязные» варианты и
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
- // Служебный ответ — это КОРОТКАЯ заглушка DeepSeek («Reading…») или
117
- // короткое уведомление о лимите. Слова про rate limit в ДЛИННОМ
118
- // ответе — это, как правило, сам агент цитирует код/логи (в транскрипте
119
- // был ровно такой случай: ответ на 1365 символов про ask() и лимиты),
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 иногда так обрывает ход: пишет «Now update README…» или
147
- // «Let me run the tests…» и замолкает. Если принять это за финал,
148
- // агент встаёт, не сделав работу. Просим продолжить и на этот раз
149
- // обязательно вызвать инструмент (или respond, если правда готово).
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
- // Пустой respond — не финал: модель позвала respond, но не написала
182
- // итог. Если так завершить, оператор не увидит ничего, а задача
183
- // «зависнет». Просим продолжить (в пределах stallRetries).
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
- // Ответ похож на вызов инструмента, но parseToolCall() его не распознал.
247
- // Используется как страховка от «агент вызвал инструмент и остановился»:
248
- // в этом случае runAgentLoop просит модель переотправить вызов, а не
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
- // Явные маркеры форматов tool-call: JSON-ключ "tool", XML/DSML-теги,
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
- // «tool» без открывающей кавычки/скобки, с мусорным префиксом
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
- // Ключи в одинарных кавычках или без кавычек: {'tool': 'Read', ...}.
292
+ // Keys in single quotes or unquoted: {'tool': 'Read', ...}.
261
293
  /[\{\[]\s*['"]?(tool|name|args)['"]?\s*:/.test(raw) ||
262
- // Обрезанный вызов: начинается как JSON-объект, но не закрыт, и в нём
263
- // есть ключ аргумента (args/command/path/...). Требуем именно открывающую
264
- // скобку в начале (после пробелов/префикса), чтобы не ловить обычную
265
- // прозу с двоеточиями вроде «path: ...».
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
- // вызова не содержит. DeepSeek регулярно так «зависает»: пишет
273
- // «Now update README to mention …», «Let me run the tests», «Сейчас проверю»
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
- // кавычках («{'tool': 'Read', 'args': {...}}») или с ключами без кавычек
727
- // («{tool: "Read", args: {...}}»). Это не валидный JSON, и без нормализации
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
- // Ключи без кавычек: {tool: ...} или , args: ... → "tool": / "args":
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
- // Модель иногда портит начало вызова: `<|tool": "Bash", "args": {...}`,
774
- // `tool": "Read", ...`, `**tool**: ...`, `- tool: ...`. В таких ответах
775
- // нет открывающей `{`, а ключ `tool` лишился первой кавычки. Если такой
776
- // ответ принять за финальный, агент молча встанет (частая «остановка»).
777
- // Восстанавливаем: срезаем мусорный префикс до слова tool, добавляем `{` и
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
- // Начало ищем с первой кавычки/скобки вокруг ключа, иначе — с слова tool.
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
- // Ключ мог потерять открывающую кавычку: tool": → "tool":.
793
- // Срезаем ведущий мусор до слова tool и нормализуем кавычки ключа.
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
- for (let i = candidates.length - 1; i >= 0; i--) {
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
- return first;
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
- return second;
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
- return third;
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
- // Псевдо-JSON (одинарные кавычки / ключи без кавычек) — нормализуем и
833
- // пробуем распарсить как обычный вызов, прежде чем идти в permissive.
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
- // Последняя попытка: «починить» испорченную голову вызова (`<|tool": ...`,
865
- // `tool": ...`, `**tool**: ...`, `- tool: ...`). Делаем это ТОЛЬКО как
866
- // fallback, после обычного разбора — иначе легко испортить валидный JSON
867
- // (например, массив вызовов начинается с `[`, внутри которого `{"tool":`).
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 = [