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.
- package/dist/agent-loop.js +118 -82
- package/dist/browser.js +40 -41
- package/dist/config-menu.js +25 -25
- package/dist/config.js +5 -5
- package/dist/gitTools.js +4 -4
- package/dist/i18n.js +11 -7
- package/dist/index.js +153 -150
- package/dist/input.js +82 -77
- 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 +23 -0
- 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 +14 -14
- package/package.json +1 -1
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, 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,7 +43,7 @@ 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
|
}
|
|
@@ -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
|
-
//
|
|
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
|
-
//
|
|
65
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
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
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
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
|
-
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
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
|
-
//
|
|
247
|
-
//
|
|
248
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
270
|
+
// Keys in single quotes or unquoted: {'tool': 'Read', ...}.
|
|
261
271
|
/[\{\[]\s*['"]?(tool|name|args)['"]?\s*:/.test(raw) ||
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
//
|
|
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
|
-
//
|
|
273
|
-
//
|
|
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
|
-
//
|
|
727
|
-
// (
|
|
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
|
-
//
|
|
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
|
-
//
|
|
774
|
-
// `tool": "Read", ...`, `**tool**: ...`, `- tool: ...`.
|
|
775
|
-
//
|
|
776
|
-
//
|
|
777
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
793
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
833
|
-
|
|
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
|
-
//
|
|
865
|
-
// `tool": ...`, `**tool**: ...`, `- tool: ...`).
|
|
866
|
-
// fallback,
|
|
867
|
-
// (
|
|
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
|
-
//
|
|
29
|
-
// —
|
|
30
|
-
// _isGenerating()
|
|
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
|
-
//
|
|
40
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
48
|
-
// (
|
|
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
|
-
//
|
|
104
|
-
// (
|
|
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
|
-
//
|
|
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
|
-
//
|
|
309
|
-
//
|
|
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
|
-
//
|
|
344
|
-
//
|
|
345
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
424
|
-
//
|
|
425
|
-
//
|
|
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
|
-
//
|
|
544
|
-
//
|
|
545
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
588
|
-
//
|
|
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)) {
|