agen-vektor 0.3.18 → 0.3.20
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 +49 -18
- package/dist/providers/anthropic.js +1 -1
- package/dist/providers/openai-compat.js +2 -2
- package/dist/providers/provider.js +44 -1
- package/dist/tools/shell.js +63 -8
- package/dist/tui/app.js +5 -4
- package/package.json +1 -1
package/dist/agent/loop.js
CHANGED
|
@@ -238,22 +238,53 @@ async function runAgentLoop(userRequest, opts, callbacks = {}) {
|
|
|
238
238
|
callbacks.onStatus?.(iterations === 1 ? 'Thinking' : 'Thinking…');
|
|
239
239
|
let result;
|
|
240
240
|
try {
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
241
|
+
// ANTI-COOLDOWN UX (2026-09-09): a 429 from the shared free gateway is
|
|
242
|
+
// absorbed INVISIBLY. withRetry deliberately does not ladder-retry 429;
|
|
243
|
+
// this grace loop owns rate-limit pacing: it honors the gateway's
|
|
244
|
+
// retryAfter (capped), waits quietly, and retries — the user only ever
|
|
245
|
+
// sees the status line, never an error, as long as the total budget
|
|
246
|
+
// (~120s) lasts. Only a persistently rate-limited key beyond that
|
|
247
|
+
// budget surfaces the calm "Provider busy" message. resultHistory
|
|
248
|
+
// covers the FIRST 429 round-trip too: a failure after a success is a
|
|
249
|
+
// transient gateway flap, not a dead shared key.
|
|
250
|
+
const graceDeadline = Date.now() + 120_000;
|
|
251
|
+
for (;;) {
|
|
252
|
+
try {
|
|
253
|
+
result = await (0, provider_1.withRetry)(doCall, {
|
|
254
|
+
retries: config.maxRetries,
|
|
255
|
+
signal,
|
|
256
|
+
onRetry: (attempt, err, waitMs) => {
|
|
257
|
+
// Surface the silent retry loop: without this the user stares at a
|
|
258
|
+
// frozen spinner for 1s+2s+4s while the gateway flakes.
|
|
259
|
+
// friendlyApiError keeps provider bodies (JSON, stack-ish text) out
|
|
260
|
+
// of the status bar — the raw dump leaked internals and pushed the
|
|
261
|
+
// timer/mode labels off the row.
|
|
262
|
+
callbacks.onStatus?.(`Retrying ${attempt}/${config.maxRetries} — ${(0, provider_1.friendlyApiError)(err)}, waiting ${Math.round(waitMs / 1000)}s`);
|
|
263
|
+
// A partially-streamed reply from the FAILED attempt must not leak
|
|
264
|
+
// into the UI or the next attempt's reasoning accumulator.
|
|
265
|
+
lastReasoning = '';
|
|
266
|
+
callbacks.onRetryReset?.();
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
catch (err) {
|
|
272
|
+
if (err instanceof provider_1.ProviderError &&
|
|
273
|
+
err.status === 429 &&
|
|
274
|
+
!signal?.aborted &&
|
|
275
|
+
Date.now() < graceDeadline) {
|
|
276
|
+
const waitMs = Math.min(Math.max(err.retryAfterMs ?? 30_000, 5_000), Math.max(graceDeadline - Date.now(), 1_000));
|
|
277
|
+
callbacks.onStatus?.(`Provider busy — retrying in ${Math.round(waitMs / 1000)}s`);
|
|
278
|
+
lastReasoning = '';
|
|
279
|
+
callbacks.onRetryReset?.();
|
|
280
|
+
await new Promise((r) => setTimeout(r, waitMs));
|
|
281
|
+
if (signal?.aborted)
|
|
282
|
+
throw new Error('aborted');
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
throw err;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
257
288
|
}
|
|
258
289
|
catch (err) {
|
|
259
290
|
if (err.name === 'AbortError' || err.message === 'aborted') {
|
|
@@ -270,9 +301,9 @@ async function runAgentLoop(userRequest, opts, callbacks = {}) {
|
|
|
270
301
|
}
|
|
271
302
|
if (err instanceof provider_1.ProviderError && err.status === 429) {
|
|
272
303
|
callbacks.onError?.(new Error('Rate limited (429) by the provider.'));
|
|
273
|
-
callbacks.onStatus?.('
|
|
304
|
+
callbacks.onStatus?.('Provider busy');
|
|
274
305
|
stopped = true;
|
|
275
|
-
finalContent = '!
|
|
306
|
+
finalContent = '! Provider busy — automatically retried for ~2 minutes. Try sending your message again.';
|
|
276
307
|
break;
|
|
277
308
|
}
|
|
278
309
|
callbacks.onError?.(err);
|
|
@@ -132,7 +132,7 @@ class AnthropicProvider {
|
|
|
132
132
|
if (!res.ok) {
|
|
133
133
|
const detail = safeJson(text);
|
|
134
134
|
const errObj = (detail && detail.error);
|
|
135
|
-
throw new provider_1.ProviderError(errObj?.message || `HTTP ${res.status}: ${text.slice(0, 300)}`, res.status, res.status >= 500 || res.status === 429);
|
|
135
|
+
throw new provider_1.ProviderError(errObj?.message || `HTTP ${res.status}: ${text.slice(0, 300)}`, res.status, res.status >= 500 || res.status === 429, (0, provider_1.parseRetryAfterMs)(res, text));
|
|
136
136
|
}
|
|
137
137
|
return safeJson(text) || {};
|
|
138
138
|
}
|
|
@@ -305,7 +305,7 @@ class OpenAICompatProvider {
|
|
|
305
305
|
const detail = safeJson(text);
|
|
306
306
|
const errObj = (detail && detail.error);
|
|
307
307
|
const msg = errObj?.message || text.slice(0, 300);
|
|
308
|
-
throw new provider_1.ProviderError(msg, res.status, res.status >= 500 || res.status === 429);
|
|
308
|
+
throw new provider_1.ProviderError(msg, res.status, res.status >= 500 || res.status === 429, (0, provider_1.parseRetryAfterMs)(res, text));
|
|
309
309
|
}
|
|
310
310
|
return safeJson(text) || {};
|
|
311
311
|
}
|
|
@@ -376,7 +376,7 @@ class OpenAICompatProvider {
|
|
|
376
376
|
const text = await res.text().catch(() => '');
|
|
377
377
|
const detail = safeJson(text);
|
|
378
378
|
const errObj = (detail && detail.error);
|
|
379
|
-
throw new provider_1.ProviderError(errObj?.message || `HTTP ${res.status}: ${text.slice(0, 200)}`, res.status, res.status >= 500 || res.status === 429);
|
|
379
|
+
throw new provider_1.ProviderError(errObj?.message || `HTTP ${res.status}: ${text.slice(0, 200)}`, res.status, res.status >= 500 || res.status === 429, (0, provider_1.parseRetryAfterMs)(res, text));
|
|
380
380
|
}
|
|
381
381
|
const reader = res.body.getReader();
|
|
382
382
|
const decoder = new TextDecoder();
|
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.ProviderError = void 0;
|
|
4
4
|
exports.mergeStreamDelta = mergeStreamDelta;
|
|
5
5
|
exports.friendlyApiError = friendlyApiError;
|
|
6
|
+
exports.parseRetryAfterMs = parseRetryAfterMs;
|
|
6
7
|
exports.withRetry = withRetry;
|
|
7
8
|
/**
|
|
8
9
|
* Idempotent merge of a streamed delta into the reply content so far.
|
|
@@ -43,7 +44,11 @@ function mergeStreamDelta(existing, delta) {
|
|
|
43
44
|
class ProviderError extends Error {
|
|
44
45
|
status;
|
|
45
46
|
retryable;
|
|
46
|
-
|
|
47
|
+
retryAfterMs;
|
|
48
|
+
constructor(message, status, retryable = false,
|
|
49
|
+
/** Server-advised wait before retrying (ms) — from Retry-After / retryAfter.
|
|
50
|
+
* withRetry uses it instead of the plain 1s/2s/4s ladder for 429s. */
|
|
51
|
+
retryAfterMs) {
|
|
47
52
|
super(message
|
|
48
53
|
// Upstream error bodies are often PRETTY-PRINTED JSON — the raw
|
|
49
54
|
// text carries real newlines (e.g. `HTTP 429: {\n "error": …}`).
|
|
@@ -57,6 +62,7 @@ class ProviderError extends Error {
|
|
|
57
62
|
.trim());
|
|
58
63
|
this.status = status;
|
|
59
64
|
this.retryable = retryable;
|
|
65
|
+
this.retryAfterMs = retryAfterMs;
|
|
60
66
|
this.name = 'ProviderError';
|
|
61
67
|
}
|
|
62
68
|
}
|
|
@@ -107,6 +113,37 @@ function friendlyApiError(err) {
|
|
|
107
113
|
const text = cleaned || noJson || raw;
|
|
108
114
|
return text.length > 64 ? `${text.slice(0, 63)}…` : text;
|
|
109
115
|
}
|
|
116
|
+
/**
|
|
117
|
+
* Server-advised retry delay for 429s, in ms (0 when absent). Sources, in
|
|
118
|
+
* order: the standard `Retry-After` header (delta-seconds or HTTP-date) and
|
|
119
|
+
* the VectorHead gateway's JSON body field `retryAfter` (seconds). Keeps
|
|
120
|
+
* client retries aligned with the gateway breaker instead of burning the
|
|
121
|
+
* 1s/2s/4s ladder against a still-parked shared key.
|
|
122
|
+
*/
|
|
123
|
+
function parseRetryAfterMs(res, bodyText) {
|
|
124
|
+
const cap = 60_000;
|
|
125
|
+
const ra = res.headers.get('retry-after');
|
|
126
|
+
if (ra) {
|
|
127
|
+
const secs = Number(ra);
|
|
128
|
+
if (Number.isFinite(secs) && secs >= 0)
|
|
129
|
+
return Math.min(secs * 1000, cap);
|
|
130
|
+
const at = Date.parse(ra);
|
|
131
|
+
if (Number.isFinite(at))
|
|
132
|
+
return Math.max(0, Math.min(at - Date.now(), cap));
|
|
133
|
+
}
|
|
134
|
+
if (bodyText) {
|
|
135
|
+
try {
|
|
136
|
+
const j = JSON.parse(bodyText);
|
|
137
|
+
const secs = typeof j.retryAfter === 'number' ? j.retryAfter : Number(j.retryAfter);
|
|
138
|
+
if (Number.isFinite(secs) && secs >= 0)
|
|
139
|
+
return Math.min(secs * 1000, cap);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
/* body not JSON — header-only */
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return 0;
|
|
146
|
+
}
|
|
110
147
|
/** Simple retry wrapper for provider calls. */
|
|
111
148
|
async function withRetry(fn, { retries = 3, delayMs = 1000, signal,
|
|
112
149
|
/** Called before each wait so the UI can show "retrying…" instead of a
|
|
@@ -135,6 +172,12 @@ onRetry, } = {}) {
|
|
|
135
172
|
: true;
|
|
136
173
|
if (!retryable || attempt > retries)
|
|
137
174
|
throw err;
|
|
175
|
+
// 429 is NEVER retried by the plain ladder: the agent loop owns
|
|
176
|
+
// rate-limit pacing (grace retries honoring retryAfterMs). Ladder
|
|
177
|
+
// retries here would burn attempts against a parked shared key and
|
|
178
|
+
// delay the calm “Provider busy — retrying” UX.
|
|
179
|
+
if (err instanceof ProviderError && err.status === 429)
|
|
180
|
+
throw err;
|
|
138
181
|
const wait = delayMs * Math.pow(2, attempt - 1);
|
|
139
182
|
onRetry?.(attempt, err, wait);
|
|
140
183
|
await new Promise((r) => setTimeout(r, wait));
|
package/dist/tools/shell.js
CHANGED
|
@@ -7,39 +7,94 @@ exports.createShellTool = createShellTool;
|
|
|
7
7
|
* Every command is classified by the command policy and gated by permissions.
|
|
8
8
|
*/
|
|
9
9
|
const node_child_process_1 = require("node:child_process");
|
|
10
|
+
// After the shell itself exits, wait this long for the stdio pipes to drain
|
|
11
|
+
// (normal commands flush and close within milliseconds) before giving up.
|
|
12
|
+
// A command that ORPHANS a child holding the pipes (background job, server,
|
|
13
|
+
// watcher …) would otherwise keep the tool — and the whole agent loop —
|
|
14
|
+
// hanging until the grandchild dies ("sering nyangkut di shell", 2026-09-10).
|
|
15
|
+
const ORPHAN_GRACE_MS = 500;
|
|
10
16
|
function runShell(command, cwd, opts = {}) {
|
|
11
17
|
return new Promise((resolve) => {
|
|
12
18
|
const timeoutMs = opts.timeoutMs || 120_000;
|
|
19
|
+
// Own process group (detached) → the timeout / orphan guard can kill the
|
|
20
|
+
// whole tree (shell AND grandchildren), same policy as background.ts.
|
|
13
21
|
const child = (0, node_child_process_1.spawn)(command, {
|
|
14
22
|
cwd,
|
|
15
23
|
shell: '/bin/sh',
|
|
16
24
|
env: { ...process.env, ...opts.env },
|
|
17
25
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
26
|
+
detached: true,
|
|
18
27
|
});
|
|
19
28
|
let stdout = '';
|
|
20
29
|
let stderr = '';
|
|
21
30
|
let timedOut = false;
|
|
31
|
+
let settled = false;
|
|
32
|
+
let graceTimer = null;
|
|
33
|
+
const killTree = () => {
|
|
34
|
+
if (child.pid != null) {
|
|
35
|
+
try {
|
|
36
|
+
process.kill(-child.pid, 'SIGKILL'); // detached spawn ⇒ own process group
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
/* group already gone */
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
child.kill('SIGKILL');
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
/* already gone */
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
const finish = (code) => {
|
|
50
|
+
if (settled)
|
|
51
|
+
return;
|
|
52
|
+
settled = true;
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
if (graceTimer)
|
|
55
|
+
clearTimeout(graceTimer);
|
|
56
|
+
resolve({ stdout, stderr, exitCode: code, timedOut });
|
|
57
|
+
};
|
|
22
58
|
const timer = setTimeout(() => {
|
|
23
59
|
timedOut = true;
|
|
24
|
-
child.kill(
|
|
60
|
+
killTree(); // was: child.kill() only — orphaned children kept the pipe open and the promise hanging past the timeout
|
|
61
|
+
// 'exit' fires once the shell dies and resolves immediately.
|
|
25
62
|
}, timeoutMs);
|
|
26
63
|
child.stdout.on('data', (d) => {
|
|
27
64
|
stdout += d.toString();
|
|
28
65
|
if (stdout.length > 2_000_000)
|
|
29
|
-
|
|
66
|
+
killTree();
|
|
30
67
|
});
|
|
31
68
|
child.stderr.on('data', (d) => {
|
|
32
69
|
stderr += d.toString();
|
|
33
70
|
if (stderr.length > 2_000_000)
|
|
34
|
-
|
|
71
|
+
killTree();
|
|
72
|
+
});
|
|
73
|
+
// 'exit' = the shell itself died; 'close' = shell AND stdio pipes closed.
|
|
74
|
+
// Resolve on whichever happens first with a small grace window after
|
|
75
|
+
// 'exit': a normal command flushes and closes well inside the window, so
|
|
76
|
+
// full output still arrives — but an orphaned grandchild holding the
|
|
77
|
+
// pipes can no longer hang the tool past it.
|
|
78
|
+
child.on('exit', (code) => {
|
|
79
|
+
if (timedOut) {
|
|
80
|
+
finish(code);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (graceTimer)
|
|
84
|
+
return;
|
|
85
|
+
graceTimer = setTimeout(() => {
|
|
86
|
+
killTree(); // stop the orphan so its output stops too
|
|
87
|
+
finish(code);
|
|
88
|
+
}, ORPHAN_GRACE_MS);
|
|
35
89
|
});
|
|
36
90
|
child.on('close', (code) => {
|
|
37
|
-
|
|
38
|
-
resolve({ stdout, stderr, exitCode: code, timedOut });
|
|
91
|
+
finish(code);
|
|
39
92
|
});
|
|
40
93
|
child.on('error', (e) => {
|
|
41
|
-
|
|
42
|
-
|
|
94
|
+
// set stderr BEFORE finish — the resolved object snapshots these strings
|
|
95
|
+
if (!stdout && !stderr)
|
|
96
|
+
stderr = `spawn error: ${e.message}`;
|
|
97
|
+
finish(-1);
|
|
43
98
|
});
|
|
44
99
|
});
|
|
45
100
|
}
|
|
@@ -52,7 +107,7 @@ function createShellTool() {
|
|
|
52
107
|
return {
|
|
53
108
|
definition: {
|
|
54
109
|
name: 'shell',
|
|
55
|
-
description: 'Run a shell command in the project directory. The command is classified for safety; risky commands require permission. Use for building, testing, installing, git, or any command execution. Long output is truncated. For git commits prefer the dedicated `git` tool and follow its commit-attribution guidance.',
|
|
110
|
+
description: 'Run a shell command in the project directory. The command is classified for safety; risky commands require permission. Use for building, testing, installing, git, or any command execution. Long output is truncated. Commands that keep running (dev servers, watchers, polling loops) must use run_in_background instead — a foreground shell is killed after its timeout. For git commits prefer the dedicated `git` tool and follow its commit-attribution guidance.',
|
|
56
111
|
parameters: {
|
|
57
112
|
type: 'object',
|
|
58
113
|
properties: {
|
package/dist/tui/app.js
CHANGED
|
@@ -1804,11 +1804,12 @@ class App {
|
|
|
1804
1804
|
// never shows gateway internals.
|
|
1805
1805
|
let errorContent = `! ${(0, provider_1.friendlyApiError)(err)}`;
|
|
1806
1806
|
if (exhausted && this.agent.config.provider === free_tier_1.FREE_PROVIDER_ID) {
|
|
1807
|
-
//
|
|
1808
|
-
//
|
|
1809
|
-
//
|
|
1807
|
+
// ANTI-COOLDOWN UX (2026-09-09): loop sudah meng-absorpsi 429 dgn
|
|
1808
|
+
// grace retry ~2 menit — pesan ini hanya muncul kalau gateway benar-
|
|
1809
|
+
// benar masih ramai SETELAH itu. Copy-nya tenang & pasif (penerimaan,
|
|
1810
|
+
// bukan dinding instruksi BYOK); otomatis coba lagi adalah default.
|
|
1810
1811
|
errorContent =
|
|
1811
|
-
'!
|
|
1812
|
+
'! Provider sedang ramai (akun gratis dipakai bersama). Percobaan berikutnya berjalan otomatis — tidak perlu mengatur apa pun.';
|
|
1812
1813
|
}
|
|
1813
1814
|
this.messages.push({ kind: 'error', content: errorContent, ts: Date.now() });
|
|
1814
1815
|
this.scrollToLatest();
|
package/package.json
CHANGED