@link-assistant/agent 0.13.2 → 0.13.5
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/package.json +1 -1
- package/src/bun/index.ts +42 -2
- package/src/flag/flag.ts +5 -5
- package/src/index.js +1 -3
- package/src/provider/models.ts +5 -2
- package/src/provider/retry-fetch.ts +154 -32
- package/src/session/retry.ts +6 -6
package/package.json
CHANGED
package/src/bun/index.ts
CHANGED
|
@@ -141,6 +141,14 @@ export namespace BunProc {
|
|
|
141
141
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
/**
|
|
145
|
+
* Staleness threshold for 'latest' version packages (24 hours).
|
|
146
|
+
* Packages installed as 'latest' will be refreshed after this period.
|
|
147
|
+
* This ensures users get updated packages with bug fixes and new features.
|
|
148
|
+
* @see https://github.com/link-assistant/agent/issues/177
|
|
149
|
+
*/
|
|
150
|
+
const LATEST_VERSION_STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000;
|
|
151
|
+
|
|
144
152
|
export async function install(pkg: string, version = 'latest') {
|
|
145
153
|
const mod = path.join(Global.Path.cache, 'node_modules', pkg);
|
|
146
154
|
|
|
@@ -150,11 +158,41 @@ export namespace BunProc {
|
|
|
150
158
|
|
|
151
159
|
const pkgjson = Bun.file(path.join(Global.Path.cache, 'package.json'));
|
|
152
160
|
const parsed = await pkgjson.json().catch(async () => {
|
|
153
|
-
const result = { dependencies: {} };
|
|
161
|
+
const result = { dependencies: {}, _installTime: {} };
|
|
154
162
|
await Bun.write(pkgjson.name!, JSON.stringify(result, null, 2));
|
|
155
163
|
return result;
|
|
156
164
|
});
|
|
157
|
-
|
|
165
|
+
|
|
166
|
+
// Initialize _installTime tracking if not present
|
|
167
|
+
if (!parsed._installTime) {
|
|
168
|
+
parsed._installTime = {};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Check if package is already installed with the requested version
|
|
172
|
+
const installedVersion = parsed.dependencies[pkg];
|
|
173
|
+
const installTime = parsed._installTime[pkg] as number | undefined;
|
|
174
|
+
|
|
175
|
+
if (installedVersion === version) {
|
|
176
|
+
// For 'latest' version, check if installation is stale and needs refresh
|
|
177
|
+
// This ensures users get updated packages with important fixes
|
|
178
|
+
// @see https://github.com/link-assistant/agent/issues/177 (specificationVersion v3 support)
|
|
179
|
+
if (version === 'latest' && installTime) {
|
|
180
|
+
const age = Date.now() - installTime;
|
|
181
|
+
if (age < LATEST_VERSION_STALE_THRESHOLD_MS) {
|
|
182
|
+
return mod;
|
|
183
|
+
}
|
|
184
|
+
log.info(() => ({
|
|
185
|
+
message: 'refreshing stale latest package',
|
|
186
|
+
pkg,
|
|
187
|
+
version,
|
|
188
|
+
ageMs: age,
|
|
189
|
+
threshold: LATEST_VERSION_STALE_THRESHOLD_MS,
|
|
190
|
+
}));
|
|
191
|
+
} else if (version !== 'latest') {
|
|
192
|
+
// For explicit versions, don't reinstall
|
|
193
|
+
return mod;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
158
196
|
|
|
159
197
|
// Check for dry-run mode
|
|
160
198
|
if (Flag.OPENCODE_DRY_RUN) {
|
|
@@ -205,6 +243,8 @@ export namespace BunProc {
|
|
|
205
243
|
attempt,
|
|
206
244
|
}));
|
|
207
245
|
parsed.dependencies[pkg] = version;
|
|
246
|
+
// Track installation time for 'latest' version staleness checks
|
|
247
|
+
parsed._installTime[pkg] = Date.now();
|
|
208
248
|
await Bun.write(pkgjson.name!, JSON.stringify(parsed, null, 2));
|
|
209
249
|
return mod;
|
|
210
250
|
} catch (e) {
|
package/src/flag/flag.ts
CHANGED
|
@@ -4,6 +4,11 @@ export namespace Flag {
|
|
|
4
4
|
return process.env[newKey] ?? process.env[oldKey];
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
+
function truthy(key: string) {
|
|
8
|
+
const value = process.env[key]?.toLowerCase();
|
|
9
|
+
return value === 'true' || value === '1';
|
|
10
|
+
}
|
|
11
|
+
|
|
7
12
|
function truthyCompat(newKey: string, oldKey: string): boolean {
|
|
8
13
|
const value = (getEnv(newKey, oldKey) ?? '').toLowerCase();
|
|
9
14
|
return value === 'true' || value === '1';
|
|
@@ -155,9 +160,4 @@ export namespace Flag {
|
|
|
155
160
|
export function setCompactJson(value: boolean) {
|
|
156
161
|
_compactJson = value;
|
|
157
162
|
}
|
|
158
|
-
|
|
159
|
-
function truthy(key: string) {
|
|
160
|
-
const value = process.env[key]?.toLowerCase();
|
|
161
|
-
return value === 'true' || value === '1';
|
|
162
|
-
}
|
|
163
163
|
}
|
package/src/index.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
|
|
2
|
+
import { Flag } from './flag/flag.ts';
|
|
3
3
|
import { setProcessName } from './cli/process-name.ts';
|
|
4
|
-
|
|
5
4
|
setProcessName('agent');
|
|
6
5
|
|
|
7
6
|
import { Server } from './server/server.ts';
|
|
@@ -19,7 +18,6 @@ import {
|
|
|
19
18
|
} from './json-standard/index.ts';
|
|
20
19
|
import { McpCommand } from './cli/cmd/mcp.ts';
|
|
21
20
|
import { AuthCommand } from './cli/cmd/auth.ts';
|
|
22
|
-
import { Flag } from './flag/flag.ts';
|
|
23
21
|
import { FormatError } from './cli/error.ts';
|
|
24
22
|
import { UI } from './cli/ui.ts';
|
|
25
23
|
import {
|
package/src/provider/models.ts
CHANGED
|
@@ -131,8 +131,11 @@ export namespace ModelsDev {
|
|
|
131
131
|
if (result) return result as Record<string, Provider>;
|
|
132
132
|
|
|
133
133
|
// Fallback to bundled data if cache read failed
|
|
134
|
-
|
|
135
|
-
|
|
134
|
+
// This is expected behavior when the cache is unavailable or corrupted
|
|
135
|
+
// Using info level since bundled data is a valid fallback mechanism
|
|
136
|
+
// @see https://github.com/link-assistant/agent/issues/177
|
|
137
|
+
log.info(() => ({
|
|
138
|
+
message: 'cache unavailable, using bundled data',
|
|
136
139
|
path: filepath,
|
|
137
140
|
}));
|
|
138
141
|
const json = await data();
|
|
@@ -20,7 +20,12 @@ import { Flag } from '../flag/flag';
|
|
|
20
20
|
* By wrapping fetch, we handle rate limits at the HTTP layer with time-based retries,
|
|
21
21
|
* ensuring the agent's 7-week global timeout is respected.
|
|
22
22
|
*
|
|
23
|
+
* Important: Rate limit waits use ISOLATED AbortControllers that are NOT subject to
|
|
24
|
+
* provider/stream timeouts. This prevents long rate limit waits (e.g., 15 hours) from
|
|
25
|
+
* being aborted by short provider timeouts (e.g., 5 minutes).
|
|
26
|
+
*
|
|
23
27
|
* @see https://github.com/link-assistant/agent/issues/167
|
|
28
|
+
* @see https://github.com/link-assistant/agent/issues/183
|
|
24
29
|
* @see https://github.com/vercel/ai/issues/12585
|
|
25
30
|
*/
|
|
26
31
|
|
|
@@ -125,8 +130,8 @@ export namespace RetryFetch {
|
|
|
125
130
|
log.info(() => ({
|
|
126
131
|
message: 'using retry-after value',
|
|
127
132
|
retryAfterMs,
|
|
128
|
-
delay,
|
|
129
|
-
minInterval,
|
|
133
|
+
delayMs: delay,
|
|
134
|
+
minIntervalMs: minInterval,
|
|
130
135
|
}));
|
|
131
136
|
return addJitter(delay);
|
|
132
137
|
}
|
|
@@ -140,33 +145,119 @@ export namespace RetryFetch {
|
|
|
140
145
|
log.info(() => ({
|
|
141
146
|
message: 'no retry-after header, using exponential backoff',
|
|
142
147
|
attempt,
|
|
143
|
-
backoffDelay,
|
|
144
|
-
delay,
|
|
145
|
-
minInterval,
|
|
146
|
-
maxBackoffDelay,
|
|
148
|
+
backoffDelayMs: backoffDelay,
|
|
149
|
+
delayMs: delay,
|
|
150
|
+
minIntervalMs: minInterval,
|
|
151
|
+
maxBackoffDelayMs: maxBackoffDelay,
|
|
147
152
|
}));
|
|
148
153
|
return addJitter(delay);
|
|
149
154
|
}
|
|
150
155
|
|
|
151
156
|
/**
|
|
152
157
|
* Sleep for the specified duration, but respect abort signals.
|
|
158
|
+
* Properly cleans up event listeners to prevent memory leaks.
|
|
153
159
|
*/
|
|
154
160
|
async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
155
161
|
return new Promise((resolve, reject) => {
|
|
162
|
+
// Check if already aborted before starting
|
|
163
|
+
if (signal?.aborted) {
|
|
164
|
+
reject(new DOMException('Aborted', 'AbortError'));
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
156
168
|
const timeout = setTimeout(resolve, ms);
|
|
169
|
+
|
|
157
170
|
if (signal) {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
(
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
171
|
+
const abortHandler = () => {
|
|
172
|
+
clearTimeout(timeout);
|
|
173
|
+
reject(new DOMException('Aborted', 'AbortError'));
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
signal.addEventListener('abort', abortHandler, { once: true });
|
|
177
|
+
|
|
178
|
+
// Clean up the listener when the timeout completes normally
|
|
179
|
+
// This prevents memory leaks on long-running processes
|
|
180
|
+
const originalResolve = resolve;
|
|
181
|
+
// eslint-disable-next-line no-param-reassign
|
|
182
|
+
resolve = (value) => {
|
|
183
|
+
signal.removeEventListener('abort', abortHandler);
|
|
184
|
+
originalResolve(value);
|
|
185
|
+
};
|
|
166
186
|
}
|
|
167
187
|
});
|
|
168
188
|
}
|
|
169
189
|
|
|
190
|
+
/**
|
|
191
|
+
* Create an isolated AbortController for rate limit waits.
|
|
192
|
+
*
|
|
193
|
+
* This controller is NOT connected to the request's AbortSignal, so it won't be
|
|
194
|
+
* affected by provider timeouts (default 5 minutes) or stream timeouts.
|
|
195
|
+
* It only respects the global AGENT_RETRY_TIMEOUT.
|
|
196
|
+
*
|
|
197
|
+
* However, it DOES check the user's abort signal periodically (every 10 seconds)
|
|
198
|
+
* to allow user cancellation during long rate limit waits.
|
|
199
|
+
*
|
|
200
|
+
* This solves issue #183 where long rate limit waits (e.g., 15 hours) were being
|
|
201
|
+
* aborted by the provider timeout (5 minutes).
|
|
202
|
+
*
|
|
203
|
+
* @param remainingTimeout Maximum time allowed for this wait (ms)
|
|
204
|
+
* @param userSignal Optional user abort signal to check periodically
|
|
205
|
+
* @returns An object with the signal and a cleanup function
|
|
206
|
+
* @see https://github.com/link-assistant/agent/issues/183
|
|
207
|
+
*/
|
|
208
|
+
function createIsolatedRateLimitSignal(
|
|
209
|
+
remainingTimeout: number,
|
|
210
|
+
userSignal?: AbortSignal
|
|
211
|
+
): {
|
|
212
|
+
signal: AbortSignal;
|
|
213
|
+
cleanup: () => void;
|
|
214
|
+
} {
|
|
215
|
+
const controller = new AbortController();
|
|
216
|
+
const timers: NodeJS.Timeout[] = [];
|
|
217
|
+
|
|
218
|
+
// Set a timeout based on the global AGENT_RETRY_TIMEOUT (not provider timeout)
|
|
219
|
+
const globalTimeoutId = setTimeout(() => {
|
|
220
|
+
controller.abort(
|
|
221
|
+
new DOMException(
|
|
222
|
+
'Rate limit wait exceeded global timeout',
|
|
223
|
+
'TimeoutError'
|
|
224
|
+
)
|
|
225
|
+
);
|
|
226
|
+
}, remainingTimeout);
|
|
227
|
+
timers.push(globalTimeoutId);
|
|
228
|
+
|
|
229
|
+
// Periodically check if user canceled (every 10 seconds)
|
|
230
|
+
// This allows user cancellation during long rate limit waits
|
|
231
|
+
// without being affected by provider timeouts
|
|
232
|
+
if (userSignal) {
|
|
233
|
+
const checkUserCancellation = () => {
|
|
234
|
+
if (userSignal.aborted) {
|
|
235
|
+
controller.abort(
|
|
236
|
+
new DOMException(
|
|
237
|
+
'User canceled during rate limit wait',
|
|
238
|
+
'AbortError'
|
|
239
|
+
)
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
// Check immediately and then every 10 seconds
|
|
245
|
+
checkUserCancellation();
|
|
246
|
+
const intervalId = setInterval(checkUserCancellation, 10_000);
|
|
247
|
+
timers.push(intervalId as unknown as NodeJS.Timeout);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
signal: controller.signal,
|
|
252
|
+
cleanup: () => {
|
|
253
|
+
for (const timer of timers) {
|
|
254
|
+
clearTimeout(timer);
|
|
255
|
+
clearInterval(timer as unknown as NodeJS.Timeout);
|
|
256
|
+
}
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
170
261
|
/**
|
|
171
262
|
* Check if an error is retryable (network issues, temporary failures).
|
|
172
263
|
*/
|
|
@@ -243,8 +334,8 @@ export namespace RetryFetch {
|
|
|
243
334
|
message:
|
|
244
335
|
'network error retry timeout exceeded, re-throwing error',
|
|
245
336
|
sessionID,
|
|
246
|
-
elapsed,
|
|
247
|
-
maxRetryTimeout,
|
|
337
|
+
elapsedMs: elapsed,
|
|
338
|
+
maxRetryTimeoutMs: maxRetryTimeout,
|
|
248
339
|
error: (error as Error).message,
|
|
249
340
|
}));
|
|
250
341
|
throw error;
|
|
@@ -259,7 +350,7 @@ export namespace RetryFetch {
|
|
|
259
350
|
message: 'network error, retrying',
|
|
260
351
|
sessionID,
|
|
261
352
|
attempt,
|
|
262
|
-
delay,
|
|
353
|
+
delayMs: delay,
|
|
263
354
|
error: (error as Error).message,
|
|
264
355
|
}));
|
|
265
356
|
await sleep(delay, init?.signal ?? undefined);
|
|
@@ -279,8 +370,8 @@ export namespace RetryFetch {
|
|
|
279
370
|
log.warn(() => ({
|
|
280
371
|
message: 'retry timeout exceeded in fetch wrapper, returning 429',
|
|
281
372
|
sessionID,
|
|
282
|
-
elapsed,
|
|
283
|
-
maxRetryTimeout,
|
|
373
|
+
elapsedMs: elapsed,
|
|
374
|
+
maxRetryTimeoutMs: maxRetryTimeout,
|
|
284
375
|
}));
|
|
285
376
|
return response; // Let higher-level handling take over
|
|
286
377
|
}
|
|
@@ -299,8 +390,8 @@ export namespace RetryFetch {
|
|
|
299
390
|
message:
|
|
300
391
|
'retry-after exceeds remaining timeout, returning 429 response',
|
|
301
392
|
sessionID,
|
|
302
|
-
elapsed,
|
|
303
|
-
|
|
393
|
+
elapsedMs: elapsed,
|
|
394
|
+
remainingTimeoutMs: maxRetryTimeout - elapsed,
|
|
304
395
|
}));
|
|
305
396
|
return response;
|
|
306
397
|
}
|
|
@@ -310,33 +401,64 @@ export namespace RetryFetch {
|
|
|
310
401
|
log.warn(() => ({
|
|
311
402
|
message: 'delay would exceed retry timeout, returning 429 response',
|
|
312
403
|
sessionID,
|
|
313
|
-
elapsed,
|
|
314
|
-
delay,
|
|
315
|
-
maxRetryTimeout,
|
|
404
|
+
elapsedMs: elapsed,
|
|
405
|
+
delayMs: delay,
|
|
406
|
+
maxRetryTimeoutMs: maxRetryTimeout,
|
|
316
407
|
}));
|
|
317
408
|
return response;
|
|
318
409
|
}
|
|
319
410
|
|
|
411
|
+
const remainingTimeout = maxRetryTimeout - elapsed;
|
|
412
|
+
|
|
320
413
|
log.info(() => ({
|
|
321
414
|
message: 'rate limited, will retry',
|
|
322
415
|
sessionID,
|
|
323
416
|
attempt,
|
|
324
|
-
delay,
|
|
417
|
+
delayMs: delay,
|
|
325
418
|
delayMinutes: (delay / 1000 / 60).toFixed(2),
|
|
326
|
-
|
|
327
|
-
|
|
419
|
+
delayHours: (delay / 1000 / 3600).toFixed(2),
|
|
420
|
+
elapsedMs: elapsed,
|
|
421
|
+
remainingTimeoutMs: remainingTimeout,
|
|
422
|
+
remainingTimeoutHours: (remainingTimeout / 1000 / 3600).toFixed(2),
|
|
423
|
+
isolatedSignal: true, // Indicates we're using isolated signal for this wait
|
|
328
424
|
}));
|
|
329
425
|
|
|
330
|
-
// Wait before retrying
|
|
426
|
+
// Wait before retrying using ISOLATED signal
|
|
427
|
+
// This is critical for issue #183: Rate limit waits can be hours long (e.g., 15 hours),
|
|
428
|
+
// but provider timeouts are typically 5 minutes. By using an isolated AbortController
|
|
429
|
+
// that only respects AGENT_RETRY_TIMEOUT, we prevent the provider timeout from
|
|
430
|
+
// aborting long rate limit waits.
|
|
431
|
+
//
|
|
432
|
+
// The isolated signal periodically checks the user's abort signal (every 10 seconds)
|
|
433
|
+
// to allow user cancellation during long waits.
|
|
434
|
+
const { signal: isolatedSignal, cleanup } =
|
|
435
|
+
createIsolatedRateLimitSignal(
|
|
436
|
+
remainingTimeout,
|
|
437
|
+
init?.signal ?? undefined
|
|
438
|
+
);
|
|
439
|
+
|
|
331
440
|
try {
|
|
332
|
-
await sleep(delay,
|
|
333
|
-
} catch {
|
|
334
|
-
//
|
|
441
|
+
await sleep(delay, isolatedSignal);
|
|
442
|
+
} catch (sleepError) {
|
|
443
|
+
// Check if the original request was aborted (user cancellation)
|
|
444
|
+
// In that case, we should stop retrying
|
|
445
|
+
if (init?.signal?.aborted) {
|
|
446
|
+
log.info(() => ({
|
|
447
|
+
message: 'rate limit wait aborted by user cancellation',
|
|
448
|
+
sessionID,
|
|
449
|
+
}));
|
|
450
|
+
return response;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Otherwise, it was the isolated timeout - log and return
|
|
335
454
|
log.info(() => ({
|
|
336
|
-
message: '
|
|
455
|
+
message: 'rate limit wait exceeded global timeout',
|
|
337
456
|
sessionID,
|
|
457
|
+
sleepError: String(sleepError),
|
|
338
458
|
}));
|
|
339
459
|
return response;
|
|
460
|
+
} finally {
|
|
461
|
+
cleanup();
|
|
340
462
|
}
|
|
341
463
|
}
|
|
342
464
|
};
|
package/src/session/retry.ts
CHANGED
|
@@ -94,8 +94,8 @@ export namespace SessionRetry {
|
|
|
94
94
|
message: 'retry timeout exceeded',
|
|
95
95
|
sessionID,
|
|
96
96
|
errorType,
|
|
97
|
-
elapsedTime,
|
|
98
|
-
maxTime,
|
|
97
|
+
elapsedTimeMs: elapsedTime,
|
|
98
|
+
maxTimeMs: maxTime,
|
|
99
99
|
}));
|
|
100
100
|
return { shouldRetry: false, elapsedTime, maxTime };
|
|
101
101
|
}
|
|
@@ -245,8 +245,8 @@ export namespace SessionRetry {
|
|
|
245
245
|
log.info(() => ({
|
|
246
246
|
message: 'no retry-after header, using exponential backoff',
|
|
247
247
|
attempt,
|
|
248
|
-
backoffDelay,
|
|
249
|
-
maxBackoffDelay,
|
|
248
|
+
backoffDelayMs: backoffDelay,
|
|
249
|
+
maxBackoffDelayMs: maxBackoffDelay,
|
|
250
250
|
}));
|
|
251
251
|
return addJitter(backoffDelay);
|
|
252
252
|
}
|
|
@@ -260,8 +260,8 @@ export namespace SessionRetry {
|
|
|
260
260
|
message:
|
|
261
261
|
'no response headers, using exponential backoff with conservative cap',
|
|
262
262
|
attempt,
|
|
263
|
-
backoffDelay,
|
|
264
|
-
|
|
263
|
+
backoffDelayMs: backoffDelay,
|
|
264
|
+
maxCapMs: RETRY_MAX_DELAY_NO_HEADERS,
|
|
265
265
|
}));
|
|
266
266
|
return addJitter(backoffDelay);
|
|
267
267
|
}
|