zames_pro 2.6.0 → 2.7.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.
@@ -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, 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,7 +43,7 @@ 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
  }
@@ -52,23 +53,24 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
52
53
  const MAX_MALFORMED_RETRIES = 3;
53
54
  let stallRetries = 0;
54
55
  const MAX_STALL_RETRIES = 5;
55
- // Защита от «агент встал»: DeepSeek иногда присылает финальный текст,
56
- // который лишь ОПИСЫВАЕТ следующий вызов инструмента (или рвёт ответ на
57
- // полуслове), и агент молча завершает задачу, хотя работа не сделана.
58
- // Если финальный ответ похож на «сейчас вызову …» — переспрашиваем, а не
59
- // останавливаемся. Счётчик общий, чтобы не зациклиться на болтливой модели.
56
+ // Guard against "the agent stalled": DeepSeek sometimes sends a final text
57
+ // that merely DESCRIBES the next tool call (or cuts the answer off
58
+ // mid-word), and the agent silently finishes the task even though the work
59
+ // is not done. If the final answer looks like "I'll call ... now" — we
60
+ // re-ask instead of stopping. The counter is shared so we don't loop on a
61
+ // chatty model.
60
62
  let looksDoneRetries = 0;
61
63
  const MAX_LOOKSDONE_RETRIES = 3;
62
64
  for (let i = 0; i < maxIterations; i++) {
63
65
  onThinking();
64
- // Первое сообщение (task) — пользовательский ввод: без паузы.
65
- // Последующие (tool-result и просьбы переотправить) — агентские:
66
- // с паузой, чтобы не упираться в лимит частоты.
66
+ // The first message (task) is user input: no throttle.
67
+ // Subsequent ones (tool-result and resend requests) are agent sends:
68
+ // throttled so we don't hit the rate limit.
67
69
  const isFirst = i === 0;
68
70
  const rawResponse = await browser.ask(message, { agent: !isFirst });
69
71
  await reportChat();
70
72
  transcript?.log('assistant_raw', { response: rawResponse });
71
- // Пользователь прервал генерацию (Esc/Ctrl+C).
73
+ // The user aborted generation (Esc/Ctrl+C).
72
74
  if (/^\s*\(прервано пользователем\)\s*$/.test(rawResponse)) {
73
75
  transcript?.log('user_aborted');
74
76
  return rawResponse;
@@ -87,10 +89,10 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
87
89
  }
88
90
  }
89
91
  if (!parsed) {
90
- // Ответ похож на (возможно, обрезанный) вызов инструмента. Ловим не
91
- // только явный JSON, но и XML/DSML-формы, «грязные» варианты и
92
- // незакрытые фрагменты: если такой ответ молча принять за финальный,
93
- // агент встанет, хотя модель пыталась позвать инструмент.
92
+ // The answer looks like a (possibly truncated) tool call. We catch not
93
+ // only explicit JSON but also XML/DSML forms, "dirty" variants and
94
+ // unclosed fragments: if such an answer is silently taken as final, the
95
+ // agent stalls even though the model tried to call a tool.
94
96
  const looksLikeToolCall = responseLooksLikeToolCall(rawResponse);
95
97
  if (looksLikeToolCall && malformedRetries < MAX_MALFORMED_RETRIES) {
96
98
  malformedRetries++;
@@ -113,11 +115,11 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
113
115
  continue;
114
116
  }
115
117
  const trimmed = (rawResponse || '').trim();
116
- // Служебный ответ — это КОРОТКАЯ заглушка DeepSeek («Reading…») или
117
- // короткое уведомление о лимите. Слова про rate limit в ДЛИННОМ
118
- // ответе — это, как правило, сам агент цитирует код/логи (в транскрипте
119
- // был ровно такой случай: ответ на 1365 символов про ask() и лимиты),
120
- // и принимать его за «служебный» нельзя, иначе агент зря переспрашивает.
118
+ // A service answer is a SHORT DeepSeek placeholder ("Reading…") or a
119
+ // short rate-limit notice. Words about the rate limit in a LONG answer
120
+ // are usually the agent itself quoting code/logs (the transcript had
121
+ // exactly such a case: a 1365-char answer about ask() and limits), and
122
+ // it must not be taken as "service", otherwise the agent re-asks in vain.
121
123
  const looksService = !trimmed ||
122
124
  trimmed.length < 2 ||
123
125
  /^(reading|thinking|searching|analyzing|generating|stop|остановить|читаю|думаю|поиск|анализ)[\s.…]*$/i.test(trimmed) ||
@@ -142,11 +144,11 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
142
144
  'Если задача выполнена — вызови инструмент respond с итоговым сообщением.';
143
145
  continue;
144
146
  }
145
- // Ответ похож на «сейчас вызову инструмент», но вызова в нём нет.
146
- // DeepSeek иногда так обрывает ход: пишет «Now update README…» или
147
- // «Let me run the tests…» и замолкает. Если принять это за финал,
148
- // агент встаёт, не сделав работу. Просим продолжить и на этот раз
149
- // обязательно вызвать инструмент (или respond, если правда готово).
147
+ // The answer looks like "I'll call a tool now", but contains no call.
148
+ // DeepSeek sometimes cuts the turn like this: writes "Now update
149
+ // README…" or "Let me run the tests…" and goes silent. If this is taken
150
+ // as final, the agent stalls without doing the work. We ask it to
151
+ // continue and to actually call a tool this time (or respond if truly done).
150
152
  if (looksLikeUnfinishedWork(trimmed) && looksDoneRetries < MAX_LOOKSDONE_RETRIES) {
151
153
  looksDoneRetries++;
152
154
  transcript?.log('unfinished_retry', {
@@ -168,6 +170,14 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
168
170
  'сообщением оператору.';
169
171
  continue;
170
172
  }
173
+ // All re-ask attempts are exhausted, yet the answer still looks like a
174
+ // tool call. Most likely this is a silent stall: we show the operator a
175
+ // warning in the terminal (not only in the transcript) so they see the
176
+ // problem immediately instead of wondering why the agent stalled.
177
+ if (responseLooksLikeToolCall(rawResponse)) {
178
+ transcript?.log('suspicious_final', { response: rawResponse });
179
+ onWarning(translate(locale)('msg.suspicious_stop'));
180
+ }
171
181
  onAssistantMessage(rawResponse);
172
182
  transcript?.log('assistant_final', { message: rawResponse });
173
183
  return rawResponse;
@@ -178,9 +188,9 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
178
188
  const msg = typeof respondCall.args.message === 'string'
179
189
  ? respondCall.args.message
180
190
  : String(respondCall.args.message ?? '');
181
- // Пустой respond — не финал: модель позвала respond, но не написала
182
- // итог. Если так завершить, оператор не увидит ничего, а задача
183
- // «зависнет». Просим продолжить (в пределах stallRetries).
191
+ // An empty respond is not final: the model called respond but wrote no
192
+ // summary. Finishing like this would show the operator nothing and the
193
+ // task would "hang". We ask it to continue (within stallRetries).
184
194
  if (!msg.trim() && stallRetries < MAX_STALL_RETRIES) {
185
195
  stallRetries++;
186
196
  transcript?.log('empty_respond', { attempt: stallRetries });
@@ -243,45 +253,45 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
243
253
  }
244
254
  return 'Достигнут лимит итераций.';
245
255
  }
246
- // Ответ похож на вызов инструмента, но parseToolCall() его не распознал.
247
- // Используется как страховка от «агент вызвал инструмент и остановился»:
248
- // в этом случае runAgentLoop просит модель переотправить вызов, а не
249
- // завершает задачу. Ловим и явные форматы, и «поломанные» головы вызова
250
- // (`<|tool": ...`, `**tool**:`, `tool": ...`), и обрезанные вызовы.
256
+ // The answer looks like a tool call, but parseToolCall() did not recognize it.
257
+ // Used as a safeguard against "the agent called a tool and stopped": in that
258
+ // case runAgentLoop asks the model to resend the call instead of finishing the
259
+ // task. We catch both explicit formats and "broken" call heads
260
+ // (`<|tool": ...`, `**tool**:`, `tool": ...`), and truncated calls.
251
261
  export function responseLooksLikeToolCall(rawResponse) {
252
262
  const raw = rawResponse || '';
253
263
  return (
254
- // Явные маркеры форматов tool-call: JSON-ключ "tool", XML/DSML-теги,
255
- // function_call и т.п.
264
+ // Explicit tool-call format markers: the JSON key "tool", XML/DSML tags,
265
+ // function_call, etc.
256
266
  /("tool"\s*:|\btool_calls?\b|\binvoke\b|\bparameter\b|DSML|function_call)/i.test(raw) ||
257
- // «tool» без открывающей кавычки/скобки, с мусорным префиксом
258
- // (`<|tool":`, `**tool**:`, `- tool:`): ключ вызова, а не проза.
267
+ // "tool" without an opening quote/bracket, with a junk prefix
268
+ // (`<|tool":`, `**tool**:`, `- tool:`): a call key, not prose.
259
269
  /(^|[^A-Za-z0-9_])(?:\*\*)?tool(?:\*\*)?["'`\u2018\u2019\u201c\u201d]*\s*:/.test(raw) ||
260
- // Ключи в одинарных кавычках или без кавычек: {'tool': 'Read', ...}.
270
+ // Keys in single quotes or unquoted: {'tool': 'Read', ...}.
261
271
  /[\{\[]\s*['"]?(tool|name|args)['"]?\s*:/.test(raw) ||
262
- // Обрезанный вызов: начинается как JSON-объект, но не закрыт, и в нём
263
- // есть ключ аргумента (args/command/path/...). Требуем именно открывающую
264
- // скобку в начале (после пробелов/префикса), чтобы не ловить обычную
265
- // прозу с двоеточиями вроде «path: ...».
272
+ // Truncated call: starts like a JSON object but is not closed, and has an
273
+ // argument key (args/command/path/...). We require the opening bracket at
274
+ // the start (after spaces/prefix) so we don't catch ordinary prose with
275
+ // colons like "path: ...".
266
276
  /^\s*[\[\{]/.test(raw) &&
267
277
  /["']?(?:tool|args|command|path|old_string|content|content_base64)["']?\s*:/.test(raw) ||
268
278
  /<\s*\|?\s*(DSML|invoke|parameter)/i.test(raw) ||
269
279
  /^\s*\[?\s*\{[^}]*$/.test(raw.trim()));
270
280
  }
271
- // Текст, который обещает вызов инструмента в будущем времени, но самого
272
- // вызова не содержит. DeepSeek регулярно так «зависает»: пишет
273
- // «Now update README to mention …», «Let me run the tests», «Сейчас проверю»
274
- // и останавливается. Такие ответы нельзя принимать за финальные — иначе
275
- // агент встаёт, не выполнив работу. Держим эвристику узкой (будущее время /
276
- // намерение), чтобы не ловить обычные отчёты о выполненной работе.
281
+ // Text that promises a tool call in the future tense but contains no call
282
+ // itself. DeepSeek regularly "hangs" like this: it writes
283
+ // "Now update README to mention …", "Let me run the tests", "I'll check now"
284
+ // and stops. Such answers must not be taken as final — otherwise the agent
285
+ // stalls without doing the work. We keep the heuristic narrow (future tense /
286
+ // intent) so we don't catch ordinary reports of completed work.
277
287
  function looksLikeUnfinishedWork(text) {
278
288
  const t = (text || '').trim();
279
289
  if (!t)
280
290
  return false;
281
- // Длинные ответы (отчёты) не трогаем — там может быть что угодно.
291
+ // Long answers (reports) are left alone — anything can be in there.
282
292
  if (t.length > 600)
283
293
  return false;
284
- // Уже есть финальный маркер — считаем ответ завершённым.
294
+ // A final marker is already present — treat the answer as complete.
285
295
  if (/\b(done|finished|completed|готово|выполнено|завершено)\b/i.test(t)) {
286
296
  return false;
287
297
  }
@@ -722,17 +732,18 @@ function findMatching(text, openIdx, openCh, closeCh) {
722
732
  }
723
733
  return -1;
724
734
  }
725
- // Модель иногда отдаёт вызов инструмента с ключами/строками в одинарных
726
- // кавычках («{'tool': 'Read', 'args': {...}}») или с ключами без кавычек
727
- // («{tool: "Read", args: {...}}»). Это не валидный JSON, и без нормализации
728
- // такой ответ молча принимается за финальный — агент встаёт, не вызвав
729
- // инструмент. Приводим его к двойным кавычкам.
735
+ // The model sometimes returns a tool call with single-quoted keys/strings
736
+ // ("{'tool': 'Read', 'args': {...}}") or unquoted keys
737
+ // ("{tool: \"Read\", args: {...}}"). This is not valid JSON, and without
738
+ // normalization such an answer is silently taken as final — the agent stalls
739
+ // without calling a tool. We normalize it to double quotes.
730
740
  function normalizePseudoJson(str) {
731
- // Ключи без кавычек: {tool: ...} или , args: ... → "tool": / "args":
741
+ // Unquoted keys: {tool: ...} or , args: ... → "tool": / "args":
732
742
  let out = str.replace(/([\{\[]\s*)([A-Za-z_][A-Za-z0-9_]*)\s*:/g, '$1"$2":');
733
743
  out = out.replace(/,\s*([A-Za-z_][A-Za-z0-9_]*)\s*:/g, ', "$1":');
734
- // Одинарные кавычки → двойные. Не трогаем содержимое уже двойных строк,
735
- // идущее подряд, и экранируем случайные двойные внутри одинарных.
744
+ // Single quotes → double quotes. We don't touch the content of already
745
+ // double-quoted strings in a row, and escape stray double quotes inside
746
+ // single quotes.
736
747
  let res = '';
737
748
  let inDouble = false;
738
749
  let inSingle = false;
@@ -770,27 +781,27 @@ function normalizePseudoJson(str) {
770
781
  }
771
782
  return res;
772
783
  }
773
- // Модель иногда портит начало вызова: `<|tool": "Bash", "args": {...}`,
774
- // `tool": "Read", ...`, `**tool**: ...`, `- tool: ...`. В таких ответах
775
- // нет открывающей `{`, а ключ `tool` лишился первой кавычки. Если такой
776
- // ответ принять за финальный, агент молча встанет (частая «остановка»).
777
- // Восстанавливаем: срезаем мусорный префикс до слова tool, добавляем `{` и
778
- // доводим кавычки ключа до парных.
784
+ // The model sometimes corrupts the head of a call: `<|tool": "Bash", "args": {...}`,
785
+ // `tool": "Read", ...`, `**tool**: ...`, `- tool: ...`. Such answers have no
786
+ // opening `{`, and the `tool` key lost its first quote. If such an answer is
787
+ // taken as final, the agent silently stalls (a frequent "stop").
788
+ // We repair it: trim the junk prefix up to the word tool, add `{` and
789
+ // balance the key quotes.
779
790
  function repairToolCallPreamble(text) {
780
791
  const t = (text || '').trim();
781
792
  const m = t.match(/(?:^|[^A-Za-z0-9_])(?:\*\*)?(tool)(?:\*\*)?["'`\u2018\u2019\u201c\u201d]*\s*:/);
782
793
  if (!m || m.index === undefined)
783
794
  return null;
784
- // Начало ищем с первой кавычки/скобки вокруг ключа, иначе — с слова tool.
795
+ // We look for the start from the first quote/bracket around the key, otherwise from the word tool.
785
796
  let start = m.index;
786
797
  const brace = t.indexOf('{', Math.max(0, start - 1));
787
798
  if (brace !== -1 && brace < start)
788
799
  start = brace;
789
800
  let frag = t.slice(start);
790
- // Если фрагмент не начинается с `{` — добавляем его.
801
+ // If the fragment does not start with `{` — we add it.
791
802
  if (!frag.startsWith('{')) {
792
- // Ключ мог потерять открывающую кавычку: tool": → "tool":.
793
- // Срезаем ведущий мусор до слова tool и нормализуем кавычки ключа.
803
+ // The key may have lost its opening quote: tool": → "tool".
804
+ // We trim the leading junk up to the word tool and normalize the key quotes.
794
805
  frag = frag.replace(/^[^A-Za-z0-9_]*/, '');
795
806
  frag = frag.replace(/^(?:\*\*)?(["'`\u2018\u2019\u201c\u201d]*)(tool)(?:\*\*)?["'`\u2018\u2019\u201c\u201d]*\s*:/, '"$2":');
796
807
  frag = '{' + frag;
@@ -806,8 +817,23 @@ export function parseToolCall(text) {
806
817
  .replace(/```$/i, '')
807
818
  .trim();
808
819
  const candidates = extractJsonObjects(cleaned);
809
- for (let i = candidates.length - 1; i >= 0; i--) {
820
+ // We collect EVERY recognized call, not just the first one found. The model
821
+ // often emits several separate {"tool": ...} objects in one answer instead of
822
+ // a single JSON array. Returning only one of them used to drop the rest and
823
+ // could leave the agent "stalled after a tool call" with pending work.
824
+ const collected = [];
825
+ const tryCollect = (parsed) => {
826
+ if (!parsed)
827
+ return false;
828
+ if (Array.isArray(parsed))
829
+ collected.push(...parsed);
830
+ else
831
+ collected.push(parsed);
832
+ return true;
833
+ };
834
+ for (let i = 0; i < candidates.length; i++) {
810
835
  const raw = candidates[i];
836
+ // A JSON array of calls is authoritative: if present, use all of it.
811
837
  const arrFirst = tryParseArray(raw);
812
838
  if (arrFirst)
813
839
  return arrFirst;
@@ -815,22 +841,32 @@ export function parseToolCall(text) {
815
841
  if (arrRepaired)
816
842
  return arrRepaired;
817
843
  const first = tryParse(raw);
818
- if (first)
819
- return first;
844
+ if (first) {
845
+ tryCollect(first);
846
+ continue;
847
+ }
820
848
  const repaired = raw.replace(/\\(?!["\\/bfnrtu])/g, '\\\\');
821
849
  const second = tryParse(repaired);
822
- if (second)
823
- return second;
850
+ if (second) {
851
+ tryCollect(second);
852
+ continue;
853
+ }
824
854
  const ctrl = repairRawControlChars(raw);
825
855
  const third = tryParse(ctrl);
826
- if (third)
827
- return third;
856
+ if (third) {
857
+ tryCollect(third);
858
+ continue;
859
+ }
828
860
  const ctrlArr = tryParseArray(ctrl);
829
861
  if (ctrlArr)
830
862
  return ctrlArr;
831
863
  }
832
- // Псевдо-JSON (одинарные кавычки / ключи без кавычек) — нормализуем и
833
- // пробуем распарсить как обычный вызов, прежде чем идти в permissive.
864
+ if (collected.length === 1)
865
+ return collected[0];
866
+ if (collected.length > 1)
867
+ return collected;
868
+ // Pseudo-JSON (single quotes / unquoted keys) — normalize and try to parse
869
+ // as a regular call before going permissive.
834
870
  if (/['"]?tool['"]?\s*:/.test(cleaned)) {
835
871
  const norm = normalizePseudoJson(cleaned);
836
872
  if (norm !== cleaned) {
@@ -861,10 +897,10 @@ export function parseToolCall(text) {
861
897
  const xmlCalls = parseXmlToolCalls(cleaned);
862
898
  if (xmlCalls)
863
899
  return Array.isArray(xmlCalls) ? xmlCalls : [xmlCalls];
864
- // Последняя попытка: «починить» испорченную голову вызова (`<|tool": ...`,
865
- // `tool": ...`, `**tool**: ...`, `- tool: ...`). Делаем это ТОЛЬКО как
866
- // fallback, после обычного разбора — иначе легко испортить валидный JSON
867
- // (например, массив вызовов начинается с `[`, внутри которого `{"tool":`).
900
+ // Last attempt: "fix" a corrupted call head (`<|tool": ...`,
901
+ // `tool": ...`, `**tool**: ...`, `- tool: ...`). We do this ONLY as a
902
+ // fallback, after regular parsing — otherwise it's easy to corrupt valid
903
+ // JSON (e.g. an array of calls starts with `[`, containing `{"tool":`).
868
904
  const preamble = repairToolCallPreamble(cleaned);
869
905
  if (preamble && preamble !== cleaned) {
870
906
  const reps = [
package/dist/browser.js CHANGED
@@ -25,10 +25,9 @@ const SEND_SELECTORS = [
25
25
  'button[aria-label*="send" i]',
26
26
  'button[aria-label*="отправ" i]',
27
27
  ];
28
- // ВАЖНО: сюда НЕЛЬЗЯ добавлять общий 'div[role="button"][class*="ds-button--primary"]'
29
- // — под него попадает кнопка отправки, которая видна всегда, и тогда
30
- // _isGenerating() вечно возвращает true, из-за чего ответ никогда не
31
- // считается готовым.
28
+ // IMPORTANT: you MUST NOT add the generic 'div[role="button"][class*="ds-button--primary"]'
29
+ // here — it matches the send button, which is always visible, and then
30
+ // _isGenerating() always returns true, so the answer is never considered ready.
32
31
  const STOP_SELECTORS = [
33
32
  'div[role="button"][aria-label*="stop" i]',
34
33
  'div[role="button"][aria-label*="останов" i]',
@@ -36,16 +35,16 @@ const STOP_SELECTORS = [
36
35
  'button:has-text("Остановить")',
37
36
  'button[aria-label*="Stop" i]',
38
37
  ];
39
- // Служебные статусы интерфейса DeepSeek, которые НЕ являются ответом модели.
40
- // Иначе агент принимает статус (Reading...) за ответ и ломает разбор.
38
+ // DeepSeek UI service statuses that are NOT the model's answer.
39
+ // Otherwise the agent takes a status (Reading...) for an answer and breaks parsing.
41
40
  const STATUS_RE = /^(reading|thinking|searching|analyzing|generating|stop|остановить|читаю|думаю|поиск|анализ)[\s.…]*$/i;
42
- // Ответ DeepSeek при превышении лимита частоты.
41
+ // DeepSeek's answer when the rate limit is exceeded.
43
42
  const RATE_LIMIT_RE = /(messages? too frequent|too many requests|rate limit|слишком часто|повторите позже|try again later)/i;
44
43
  export function isRateLimitText(text) {
45
44
  return RATE_LIMIT_RE.test(String(text || ''));
46
45
  }
47
- // Ошибка «слишком часто»: отличается от прочих, чтобы ask() ждал долго
48
- // (лимиты DeepSeek сбрасываются за минуты) и повторял отправку сам.
46
+ // The "too frequent" error: distinct from others so ask() waits a long time
47
+ // (DeepSeek limits reset over minutes) and retries the send itself.
49
48
  export class RateLimitError extends Error {
50
49
  constructor(detail) {
51
50
  super('Messages too frequent. Try again later. ' + detail);
@@ -100,9 +99,9 @@ export class DeepSeekBrowser {
100
99
  maxRateLimitRetries;
101
100
  _lastSentAt;
102
101
  _abort;
103
- // Пользователь нажал Esc/Ctrl+C — «стоп» для ВСЕЙ текущей пачки задач
104
- // (включая очередь). В отличие от _abort (сбрасывается на каждую
105
- // отправку), этот флаг живёт до явного запуска новой задачи с промпта.
102
+ // The user pressed Esc/Ctrl+C — a "stop" for the WHOLE current batch of
103
+ // tasks (including the queue). Unlike _abort (reset on every send), this
104
+ // flag lives until a new task is explicitly started from the prompt.
106
105
  _stopped;
107
106
  context;
108
107
  page;
@@ -284,9 +283,9 @@ export class DeepSeekBrowser {
284
283
  return null;
285
284
  }
286
285
  async _readLastAnswerText() {
287
- // Если удалось перехватить сырой текст ответа по сети (без рендер-
288
- // искажений DeepSeek) и он относится к текущему ответу — отдаём его.
289
- // Это защищает $, экранированные переводы строк и т.п. в аргументах.
286
+ // If we managed to intercept the raw answer text over the network
287
+ // (without DeepSeek's render distortions) and it belongs to the current
288
+ // answer — we return it. This protects $, escaped newlines, etc. in arguments.
290
289
  if (this._netCapture && this._netCaptureAt >= this._lastSentAt) {
291
290
  return this._netCapture;
292
291
  }
@@ -304,9 +303,9 @@ export class DeepSeekBrowser {
304
303
  return out.replace(new RegExp(NL + '{3,}', 'g'), NL + NL).trim();
305
304
  }, ANSWER_SELECTORS);
306
305
  }
307
- // Читаем ТОЛЬКО видимые тосты/уведомления/ошибки, а не весь текст
308
- // страницы. Иначе ловим «try again later» из служебных/скрытых блоков
309
- // и уходим в ложное ожидание лимита на 5 минут.
306
+ // We read ONLY visible toasts/notifications/errors, not the whole page
307
+ // text. Otherwise we catch "try again later" from service/hidden blocks
308
+ // and go into a false 5-minute rate-limit wait.
310
309
  async _readPageText() {
311
310
  return await this.page
312
311
  .evaluate(() => {
@@ -340,9 +339,9 @@ export class DeepSeekBrowser {
340
339
  return '';
341
340
  return raw;
342
341
  }
343
- // Найти кнопку Stop в интерфейсе DeepSeek. Полагаться только на класс
344
- // нельзя: во время генерации кнопка отправки (та же circle-кнопка)
345
- // меняет иконку на «квадрат» (stop), сохраняя классы.
342
+ // Find the Stop button in the DeepSeek UI. We can't rely on the class
343
+ // alone: during generation the send button (the same circle button)
344
+ // changes its icon to a "square" (stop) while keeping the classes.
346
345
  async _stopButtonVisible() {
347
346
  const explicit = await this._findVisible(STOP_SELECTORS, 250);
348
347
  if (explicit)
@@ -361,7 +360,7 @@ export class DeepSeekBrowser {
361
360
  (b.textContent || '')).toLowerCase();
362
361
  if (/stop|останов/.test(label))
363
362
  return true;
364
- // Иконка-квадрат = кнопка Stop; стрелка (path без rect) = отправка.
363
+ // A square icon = Stop button; an arrow (path without rect) = send.
365
364
  const svg = b.querySelector('svg');
366
365
  if (svg && svg.querySelector('rect'))
367
366
  return true;
@@ -420,9 +419,9 @@ export class DeepSeekBrowser {
420
419
  }
421
420
  catch (e) {
422
421
  lastErr = e;
423
- // Лимит частоты: DeepSeek не принял сообщение. Ждём долго и
424
- // повторяем отправку в ТОТ ЖЕ чат (без newChat — иначе теряется
425
- // контекст). Паузы не расходуют обычные попытки ask().
422
+ // Rate limit: DeepSeek did not accept the message. We wait a long time
423
+ // and resend into the SAME chat (without newChat — otherwise the
424
+ // context is lost). The waits don't consume the regular ask() attempts.
426
425
  if (e instanceof RateLimitError) {
427
426
  rateLimitRetries++;
428
427
  if (rateLimitRetries > this.maxRateLimitRetries) {
@@ -495,9 +494,9 @@ export class DeepSeekBrowser {
495
494
  await this.page.keyboard.insertText(text);
496
495
  }
497
496
  }
498
- // Пауза между отправками. Применяется ТОЛЬКО к сообщениям агента
499
- // (tool-result, system-prompt), чтобы не упираться в лимит частоты.
500
- // Пользовательский ввод отправляется без задержки.
497
+ // Pause between sends. Applied ONLY to agent messages
498
+ // (tool-result, system-prompt) so we don't hit the rate limit.
499
+ // User input is sent without delay.
501
500
  async _waitForSendSlot(agent) {
502
501
  if (!agent)
503
502
  return;
@@ -510,7 +509,7 @@ export class DeepSeekBrowser {
510
509
  await this.page.waitForTimeout(gap);
511
510
  }
512
511
  async _askOnce(prompt, { timeout, agent }) {
513
- // Сбрасываем флаг прерывания ТОЛЬКО в самом начале отправки.
512
+ // We reset the abort flag ONLY at the very start of the send.
514
513
  this._abort = false;
515
514
  const input = await this._findVisible(INPUT_SELECTORS, 10_000);
516
515
  if (!input) {
@@ -540,9 +539,9 @@ export class DeepSeekBrowser {
540
539
  await this.page.keyboard.press('Enter');
541
540
  }
542
541
  this._lastSentAt = Date.now();
543
- // Ждём старта: либо появился Stop, либо изменился текст ответа,
544
- // либо вырос общий объём текста на странице. Параллельно ловим
545
- // тост о превышении лимита частоты (только тосты, не весь body).
542
+ // Wait for the start: either Stop appeared, or the answer text changed,
543
+ // or the total amount of text on the page grew. In parallel we catch
544
+ // the rate-limit toast (only toasts, not the whole body).
546
545
  const startDeadline = Date.now() + 15_000;
547
546
  const startBodyLen = await this.page
548
547
  .evaluate(() => document.body.innerText.length)
@@ -567,15 +566,15 @@ export class DeepSeekBrowser {
567
566
  await this.page.waitForTimeout(300);
568
567
  }
569
568
  if (!started) {
570
- // Больше НЕ проверяем лимит по всему тексту страницы — это давало
571
- // ложные срабатывания и 5-минутные паузы. Просто сообщаем, что
572
- // генерация не началась.
569
+ // We NO LONGER check the limit over the whole page text — that caused
570
+ // false positives and 5-minute waits. We just report that
571
+ // generation did not start.
573
572
  throw new Error('Ответ не начал генерироваться за 15с. Возможно, сообщение не отправилось.');
574
573
  }
575
- // Ждём, пока ответ перестанет меняться. Условие "не генерируется"
576
- // проверяем через рост текста, а НЕ через _isGenerating().
577
- // Параллельно ловим тост лимита частоты, если он всплывёт во время
578
- // генерации (читаем только тосты, ложных срабатываний нет).
574
+ // Wait until the answer stops changing. We check the "not generating"
575
+ // condition via text growth, NOT via _isGenerating().
576
+ // In parallel we catch the rate-limit toast if it pops up during
577
+ // generation (we read only toasts, so there are no false positives).
579
578
  const deadline = Date.now() + timeout;
580
579
  let last = '';
581
580
  let stable = 0;
@@ -584,8 +583,8 @@ export class DeepSeekBrowser {
584
583
  if (this._abort) {
585
584
  return last || '(прервано пользователем)';
586
585
  }
587
- // Тост лимита проверяем не каждый тик, а раз в ~5 тиков, чтобы не
588
- // дёргать DOM лишний раз.
586
+ // We check the limit toast not every tick but about once per 5 ticks,
587
+ // so we don't poke the DOM unnecessarily.
589
588
  if (tick++ % 5 === 0) {
590
589
  const pageText = await this._readPageText();
591
590
  if (isRateLimitText(pageText)) {