zames_pro 2.5.1 → 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/README.md +32 -0
- package/dist/agent-loop.js +224 -49
- package/dist/browser.js +41 -42
- package/dist/config-menu.js +231 -0
- package/dist/config.js +143 -0
- package/dist/gitTools.js +4 -4
- package/dist/i18n.js +280 -0
- package/dist/index.js +548 -319
- package/dist/input.js +137 -67
- 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 +30 -52
- package/dist/system-prompt.js +31 -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 +14 -14
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -60,6 +60,38 @@ zames --help
|
|
|
60
60
|
Global config: `~/.zames/config.json`
|
|
61
61
|
Local (per project): `.zamesrc.json`
|
|
62
62
|
|
|
63
|
+
You can view and change settings without leaving the agent — use the
|
|
64
|
+
`/config` command. Run it without arguments to open an interactive menu
|
|
65
|
+
(↑/↓ to move, Enter to change, `d` to reset, `q` to quit). Booleans and enums
|
|
66
|
+
toggle in place; numbers and strings open an input prompt.
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
/config interactive settings menu
|
|
70
|
+
/config list print all editable settings
|
|
71
|
+
/config get <path> show a setting
|
|
72
|
+
/config set <path> <value> change a setting
|
|
73
|
+
/config reset <path> reset a setting to its default
|
|
74
|
+
/config path show config file paths
|
|
75
|
+
/config lang <ru|en> switch interface and agent language
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Examples:
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
/config set maxIterations 20
|
|
82
|
+
/config set confirmation.bash false
|
|
83
|
+
/config lang en
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Changes are written to the project `.zamesrc.json` and applied right away
|
|
87
|
+
(where possible without a restart).
|
|
88
|
+
|
|
89
|
+
### Language
|
|
90
|
+
|
|
91
|
+
`/config lang ru` or `/config lang en` switches both the interface language
|
|
92
|
+
(help, messages, spinner) and the language the agent answers you in. The
|
|
93
|
+
locale lives in `ui.locale` in the config file.
|
|
94
|
+
|
|
63
95
|
Agent data is stored in `~/.zames`: browser profile, logs, undo history, self-review snapshots.
|
|
64
96
|
|
|
65
97
|
## License
|
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;
|
|
@@ -35,13 +36,14 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
35
36
|
workdir,
|
|
36
37
|
tools,
|
|
37
38
|
gitContext: gitText,
|
|
39
|
+
locale,
|
|
38
40
|
});
|
|
39
41
|
transcript?.log('system_prompt', {
|
|
40
42
|
length: systemPrompt.length,
|
|
41
43
|
gitContext: gitText,
|
|
42
44
|
});
|
|
43
45
|
onThinking();
|
|
44
|
-
// system-prompt
|
|
46
|
+
// system-prompt is an agent send: throttled (agent: true).
|
|
45
47
|
await browser.ask(systemPrompt, { timeout: 60_000, agent: true });
|
|
46
48
|
await reportChat();
|
|
47
49
|
}
|
|
@@ -51,23 +53,24 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
51
53
|
const MAX_MALFORMED_RETRIES = 3;
|
|
52
54
|
let stallRetries = 0;
|
|
53
55
|
const MAX_STALL_RETRIES = 5;
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
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.
|
|
59
62
|
let looksDoneRetries = 0;
|
|
60
63
|
const MAX_LOOKSDONE_RETRIES = 3;
|
|
61
64
|
for (let i = 0; i < maxIterations; i++) {
|
|
62
65
|
onThinking();
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
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.
|
|
66
69
|
const isFirst = i === 0;
|
|
67
70
|
const rawResponse = await browser.ask(message, { agent: !isFirst });
|
|
68
71
|
await reportChat();
|
|
69
72
|
transcript?.log('assistant_raw', { response: rawResponse });
|
|
70
|
-
//
|
|
73
|
+
// The user aborted generation (Esc/Ctrl+C).
|
|
71
74
|
if (/^\s*\(прервано пользователем\)\s*$/.test(rawResponse)) {
|
|
72
75
|
transcript?.log('user_aborted');
|
|
73
76
|
return rawResponse;
|
|
@@ -86,14 +89,11 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
86
89
|
}
|
|
87
90
|
}
|
|
88
91
|
if (!parsed) {
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
const looksLikeToolCall =
|
|
94
|
-
/\{\s*"?(tool|name|args)"?\s*:/.test(rawResponse) ||
|
|
95
|
-
/<\s*\|?\s*(DSML|invoke|parameter)/i.test(rawResponse) ||
|
|
96
|
-
/^\s*\[?\s*\{[^}]*$/.test(rawResponse.trim());
|
|
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.
|
|
96
|
+
const looksLikeToolCall = responseLooksLikeToolCall(rawResponse);
|
|
97
97
|
if (looksLikeToolCall && malformedRetries < MAX_MALFORMED_RETRIES) {
|
|
98
98
|
malformedRetries++;
|
|
99
99
|
transcript?.log('malformed_toolcall', {
|
|
@@ -115,11 +115,11 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
115
115
|
continue;
|
|
116
116
|
}
|
|
117
117
|
const trimmed = (rawResponse || '').trim();
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
//
|
|
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.
|
|
123
123
|
const looksService = !trimmed ||
|
|
124
124
|
trimmed.length < 2 ||
|
|
125
125
|
/^(reading|thinking|searching|analyzing|generating|stop|остановить|читаю|думаю|поиск|анализ)[\s.…]*$/i.test(trimmed) ||
|
|
@@ -144,11 +144,11 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
144
144
|
'Если задача выполнена — вызови инструмент respond с итоговым сообщением.';
|
|
145
145
|
continue;
|
|
146
146
|
}
|
|
147
|
-
//
|
|
148
|
-
// DeepSeek
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
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).
|
|
152
152
|
if (looksLikeUnfinishedWork(trimmed) && looksDoneRetries < MAX_LOOKSDONE_RETRIES) {
|
|
153
153
|
looksDoneRetries++;
|
|
154
154
|
transcript?.log('unfinished_retry', {
|
|
@@ -170,6 +170,14 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
170
170
|
'сообщением оператору.';
|
|
171
171
|
continue;
|
|
172
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
|
+
}
|
|
173
181
|
onAssistantMessage(rawResponse);
|
|
174
182
|
transcript?.log('assistant_final', { message: rawResponse });
|
|
175
183
|
return rawResponse;
|
|
@@ -180,9 +188,9 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
180
188
|
const msg = typeof respondCall.args.message === 'string'
|
|
181
189
|
? respondCall.args.message
|
|
182
190
|
: String(respondCall.args.message ?? '');
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
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).
|
|
186
194
|
if (!msg.trim() && stallRetries < MAX_STALL_RETRIES) {
|
|
187
195
|
stallRetries++;
|
|
188
196
|
transcript?.log('empty_respond', { attempt: stallRetries });
|
|
@@ -245,20 +253,45 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
245
253
|
}
|
|
246
254
|
return 'Достигнут лимит итераций.';
|
|
247
255
|
}
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
|
|
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.
|
|
261
|
+
export function responseLooksLikeToolCall(rawResponse) {
|
|
262
|
+
const raw = rawResponse || '';
|
|
263
|
+
return (
|
|
264
|
+
// Explicit tool-call format markers: the JSON key "tool", XML/DSML tags,
|
|
265
|
+
// function_call, etc.
|
|
266
|
+
/("tool"\s*:|\btool_calls?\b|\binvoke\b|\bparameter\b|DSML|function_call)/i.test(raw) ||
|
|
267
|
+
// "tool" without an opening quote/bracket, with a junk prefix
|
|
268
|
+
// (`<|tool":`, `**tool**:`, `- tool:`): a call key, not prose.
|
|
269
|
+
/(^|[^A-Za-z0-9_])(?:\*\*)?tool(?:\*\*)?["'`\u2018\u2019\u201c\u201d]*\s*:/.test(raw) ||
|
|
270
|
+
// Keys in single quotes or unquoted: {'tool': 'Read', ...}.
|
|
271
|
+
/[\{\[]\s*['"]?(tool|name|args)['"]?\s*:/.test(raw) ||
|
|
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: ...".
|
|
276
|
+
/^\s*[\[\{]/.test(raw) &&
|
|
277
|
+
/["']?(?:tool|args|command|path|old_string|content|content_base64)["']?\s*:/.test(raw) ||
|
|
278
|
+
/<\s*\|?\s*(DSML|invoke|parameter)/i.test(raw) ||
|
|
279
|
+
/^\s*\[?\s*\{[^}]*$/.test(raw.trim()));
|
|
280
|
+
}
|
|
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.
|
|
254
287
|
function looksLikeUnfinishedWork(text) {
|
|
255
288
|
const t = (text || '').trim();
|
|
256
289
|
if (!t)
|
|
257
290
|
return false;
|
|
258
|
-
//
|
|
291
|
+
// Long answers (reports) are left alone — anything can be in there.
|
|
259
292
|
if (t.length > 600)
|
|
260
293
|
return false;
|
|
261
|
-
//
|
|
294
|
+
// A final marker is already present — treat the answer as complete.
|
|
262
295
|
if (/\b(done|finished|completed|готово|выполнено|завершено)\b/i.test(t)) {
|
|
263
296
|
return false;
|
|
264
297
|
}
|
|
@@ -699,6 +732,82 @@ function findMatching(text, openIdx, openCh, closeCh) {
|
|
|
699
732
|
}
|
|
700
733
|
return -1;
|
|
701
734
|
}
|
|
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.
|
|
740
|
+
function normalizePseudoJson(str) {
|
|
741
|
+
// Unquoted keys: {tool: ...} or , args: ... → "tool": / "args":
|
|
742
|
+
let out = str.replace(/([\{\[]\s*)([A-Za-z_][A-Za-z0-9_]*)\s*:/g, '$1"$2":');
|
|
743
|
+
out = out.replace(/,\s*([A-Za-z_][A-Za-z0-9_]*)\s*:/g, ', "$1":');
|
|
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.
|
|
747
|
+
let res = '';
|
|
748
|
+
let inDouble = false;
|
|
749
|
+
let inSingle = false;
|
|
750
|
+
for (let i = 0; i < out.length; i++) {
|
|
751
|
+
const c = out[i];
|
|
752
|
+
if (c === '\\' && (inDouble || inSingle)) {
|
|
753
|
+
res += c;
|
|
754
|
+
if (i + 1 < out.length) {
|
|
755
|
+
res += out[i + 1];
|
|
756
|
+
i++;
|
|
757
|
+
}
|
|
758
|
+
continue;
|
|
759
|
+
}
|
|
760
|
+
if (c === '"' && !inSingle) {
|
|
761
|
+
inDouble = !inDouble;
|
|
762
|
+
res += c;
|
|
763
|
+
continue;
|
|
764
|
+
}
|
|
765
|
+
if (c === "'" && !inDouble) {
|
|
766
|
+
if (!inSingle) {
|
|
767
|
+
inSingle = true;
|
|
768
|
+
res += '"';
|
|
769
|
+
}
|
|
770
|
+
else {
|
|
771
|
+
inSingle = false;
|
|
772
|
+
res += '"';
|
|
773
|
+
}
|
|
774
|
+
continue;
|
|
775
|
+
}
|
|
776
|
+
if (inSingle && c === '"') {
|
|
777
|
+
res += '\\"';
|
|
778
|
+
continue;
|
|
779
|
+
}
|
|
780
|
+
res += c;
|
|
781
|
+
}
|
|
782
|
+
return res;
|
|
783
|
+
}
|
|
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.
|
|
790
|
+
function repairToolCallPreamble(text) {
|
|
791
|
+
const t = (text || '').trim();
|
|
792
|
+
const m = t.match(/(?:^|[^A-Za-z0-9_])(?:\*\*)?(tool)(?:\*\*)?["'`\u2018\u2019\u201c\u201d]*\s*:/);
|
|
793
|
+
if (!m || m.index === undefined)
|
|
794
|
+
return null;
|
|
795
|
+
// We look for the start from the first quote/bracket around the key, otherwise from the word tool.
|
|
796
|
+
let start = m.index;
|
|
797
|
+
const brace = t.indexOf('{', Math.max(0, start - 1));
|
|
798
|
+
if (brace !== -1 && brace < start)
|
|
799
|
+
start = brace;
|
|
800
|
+
let frag = t.slice(start);
|
|
801
|
+
// If the fragment does not start with `{` — we add it.
|
|
802
|
+
if (!frag.startsWith('{')) {
|
|
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.
|
|
805
|
+
frag = frag.replace(/^[^A-Za-z0-9_]*/, '');
|
|
806
|
+
frag = frag.replace(/^(?:\*\*)?(["'`\u2018\u2019\u201c\u201d]*)(tool)(?:\*\*)?["'`\u2018\u2019\u201c\u201d]*\s*:/, '"$2":');
|
|
807
|
+
frag = '{' + frag;
|
|
808
|
+
}
|
|
809
|
+
return frag;
|
|
810
|
+
}
|
|
702
811
|
export function parseToolCall(text) {
|
|
703
812
|
if (!text || typeof text !== 'string')
|
|
704
813
|
return null;
|
|
@@ -708,8 +817,23 @@ export function parseToolCall(text) {
|
|
|
708
817
|
.replace(/```$/i, '')
|
|
709
818
|
.trim();
|
|
710
819
|
const candidates = extractJsonObjects(cleaned);
|
|
711
|
-
|
|
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++) {
|
|
712
835
|
const raw = candidates[i];
|
|
836
|
+
// A JSON array of calls is authoritative: if present, use all of it.
|
|
713
837
|
const arrFirst = tryParseArray(raw);
|
|
714
838
|
if (arrFirst)
|
|
715
839
|
return arrFirst;
|
|
@@ -717,22 +841,48 @@ export function parseToolCall(text) {
|
|
|
717
841
|
if (arrRepaired)
|
|
718
842
|
return arrRepaired;
|
|
719
843
|
const first = tryParse(raw);
|
|
720
|
-
if (first)
|
|
721
|
-
|
|
844
|
+
if (first) {
|
|
845
|
+
tryCollect(first);
|
|
846
|
+
continue;
|
|
847
|
+
}
|
|
722
848
|
const repaired = raw.replace(/\\(?!["\\/bfnrtu])/g, '\\\\');
|
|
723
849
|
const second = tryParse(repaired);
|
|
724
|
-
if (second)
|
|
725
|
-
|
|
850
|
+
if (second) {
|
|
851
|
+
tryCollect(second);
|
|
852
|
+
continue;
|
|
853
|
+
}
|
|
726
854
|
const ctrl = repairRawControlChars(raw);
|
|
727
855
|
const third = tryParse(ctrl);
|
|
728
|
-
if (third)
|
|
729
|
-
|
|
856
|
+
if (third) {
|
|
857
|
+
tryCollect(third);
|
|
858
|
+
continue;
|
|
859
|
+
}
|
|
730
860
|
const ctrlArr = tryParseArray(ctrl);
|
|
731
861
|
if (ctrlArr)
|
|
732
862
|
return ctrlArr;
|
|
733
863
|
}
|
|
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.
|
|
870
|
+
if (/['"]?tool['"]?\s*:/.test(cleaned)) {
|
|
871
|
+
const norm = normalizePseudoJson(cleaned);
|
|
872
|
+
if (norm !== cleaned) {
|
|
873
|
+
for (const raw of extractJsonObjects(norm)) {
|
|
874
|
+
const a = tryParseArray(raw);
|
|
875
|
+
if (a)
|
|
876
|
+
return a;
|
|
877
|
+
const o = tryParse(raw);
|
|
878
|
+
if (o)
|
|
879
|
+
return o;
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
}
|
|
734
883
|
const permissive = parseToolCallPermissive(cleaned) ||
|
|
735
|
-
parseToolCallPermissive(repairRawControlChars(cleaned))
|
|
884
|
+
parseToolCallPermissive(repairRawControlChars(cleaned)) ||
|
|
885
|
+
parseToolCallPermissive(normalizePseudoJson(cleaned));
|
|
736
886
|
if (permissive)
|
|
737
887
|
return { ...permissive, _permissive: true };
|
|
738
888
|
const toolIdx = cleaned.search(/["']?tool["']?\s:/);
|
|
@@ -747,6 +897,31 @@ export function parseToolCall(text) {
|
|
|
747
897
|
const xmlCalls = parseXmlToolCalls(cleaned);
|
|
748
898
|
if (xmlCalls)
|
|
749
899
|
return Array.isArray(xmlCalls) ? xmlCalls : [xmlCalls];
|
|
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":`).
|
|
904
|
+
const preamble = repairToolCallPreamble(cleaned);
|
|
905
|
+
if (preamble && preamble !== cleaned) {
|
|
906
|
+
const reps = [
|
|
907
|
+
preamble,
|
|
908
|
+
repairRawControlChars(preamble),
|
|
909
|
+
normalizePseudoJson(preamble),
|
|
910
|
+
];
|
|
911
|
+
for (const rep of reps) {
|
|
912
|
+
for (const raw of extractJsonObjects(rep)) {
|
|
913
|
+
const a = tryParseArray(raw);
|
|
914
|
+
if (a)
|
|
915
|
+
return a;
|
|
916
|
+
const o = tryParse(raw);
|
|
917
|
+
if (o)
|
|
918
|
+
return o;
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
const perm = parseToolCallPermissive(preamble);
|
|
922
|
+
if (perm)
|
|
923
|
+
return { ...perm, _permissive: true };
|
|
924
|
+
}
|
|
750
925
|
return null;
|
|
751
926
|
}
|
|
752
927
|
function extractPreToolText(text) {
|
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;
|
|
@@ -506,11 +505,11 @@ export class DeepSeekBrowser {
|
|
|
506
505
|
const gap = this.minSendIntervalMs - (Date.now() - this._lastSentAt);
|
|
507
506
|
if (gap <= 0)
|
|
508
507
|
return;
|
|
509
|
-
console.error(theme.warn(`⏳ пауза ${Math.ceil(gap / 1000)}с перед
|
|
508
|
+
console.error(theme.warn(`⏳ пауза ${Math.ceil(gap / 1000)}с перед отправкой`));
|
|
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)) {
|