@link-assistant/agent 0.19.0 → 0.19.2
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/auth/plugins.ts +4 -1
- package/src/cli/model-config.js +20 -10
- package/src/index.js +6 -1
- package/src/provider/provider.ts +13 -18
- package/src/provider/retry-fetch.ts +98 -12
- package/src/storage/storage.ts +13 -2
- package/src/util/verbose-fetch.ts +34 -1
package/package.json
CHANGED
package/src/auth/plugins.ts
CHANGED
|
@@ -1542,9 +1542,12 @@ const GooglePlugin: AuthPlugin = {
|
|
|
1542
1542
|
|
|
1543
1543
|
/**
|
|
1544
1544
|
* Check if a response status is retryable (transient error).
|
|
1545
|
+
* Includes 500/502 for intermittent server errors (#231).
|
|
1545
1546
|
*/
|
|
1546
1547
|
const isRetryableStatus = (status: number): boolean => {
|
|
1547
|
-
return
|
|
1548
|
+
return (
|
|
1549
|
+
status === 429 || status === 500 || status === 502 || status === 503
|
|
1550
|
+
);
|
|
1548
1551
|
};
|
|
1549
1552
|
|
|
1550
1553
|
/**
|
package/src/cli/model-config.js
CHANGED
|
@@ -68,29 +68,39 @@ export async function parseModelConfig(argv, outputError, outputStatus) {
|
|
|
68
68
|
modelID,
|
|
69
69
|
}));
|
|
70
70
|
|
|
71
|
-
// Validate that the model exists in the provider (#196)
|
|
72
|
-
//
|
|
73
|
-
//
|
|
71
|
+
// Validate that the model exists in the provider (#196, #231)
|
|
72
|
+
// If user explicitly specified provider/model and the model is not found,
|
|
73
|
+
// fail immediately instead of silently falling back to a different model.
|
|
74
74
|
try {
|
|
75
75
|
const { Provider } = await import('../provider/provider.ts');
|
|
76
76
|
const s = await Provider.state();
|
|
77
77
|
const provider = s.providers[providerID];
|
|
78
78
|
if (provider && !provider.info.models[modelID]) {
|
|
79
|
-
// Provider exists but model doesn't
|
|
80
|
-
|
|
81
|
-
|
|
79
|
+
// Provider exists but model doesn't — fail with a clear error (#231)
|
|
80
|
+
// Silent fallback caused kimi-k2.5-free to be routed to minimax-m2.5-free
|
|
81
|
+
const availableModels = Object.keys(provider.info.models).slice(0, 10);
|
|
82
|
+
Log.Default.error(() => ({
|
|
82
83
|
message:
|
|
83
|
-
'model not found in provider
|
|
84
|
+
'model not found in provider — refusing to proceed with explicit provider/model',
|
|
84
85
|
providerID,
|
|
85
86
|
modelID,
|
|
86
87
|
availableModels,
|
|
87
88
|
}));
|
|
89
|
+
throw new Error(
|
|
90
|
+
`Model "${modelID}" not found in provider "${providerID}". ` +
|
|
91
|
+
`Available models include: ${availableModels.join(', ')}. ` +
|
|
92
|
+
`Use --model ${providerID}/<model-id> with a valid model, or omit the provider prefix for auto-resolution.`
|
|
93
|
+
);
|
|
88
94
|
}
|
|
89
95
|
} catch (validationError) {
|
|
90
|
-
//
|
|
91
|
-
|
|
96
|
+
// Re-throw if this is our own validation error (not an infrastructure issue)
|
|
97
|
+
if (validationError?.message?.includes('not found in provider')) {
|
|
98
|
+
throw validationError;
|
|
99
|
+
}
|
|
100
|
+
// For infrastructure errors (e.g. can't load provider state), log and continue
|
|
92
101
|
Log.Default.info(() => ({
|
|
93
|
-
message:
|
|
102
|
+
message:
|
|
103
|
+
'skipping model existence validation due to infrastructure error',
|
|
94
104
|
reason: validationError?.message,
|
|
95
105
|
}));
|
|
96
106
|
}
|
package/src/index.js
CHANGED
|
@@ -27,7 +27,10 @@ import { McpCommand } from './cli/cmd/mcp.ts';
|
|
|
27
27
|
import { AuthCommand } from './cli/cmd/auth.ts';
|
|
28
28
|
import { FormatError } from './cli/error.ts';
|
|
29
29
|
import { UI } from './cli/ui.ts';
|
|
30
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
createVerboseFetch,
|
|
32
|
+
registerPendingStreamLogExitHandler,
|
|
33
|
+
} from './util/verbose-fetch.ts';
|
|
31
34
|
import {
|
|
32
35
|
runContinuousServerMode,
|
|
33
36
|
runContinuousDirectMode,
|
|
@@ -822,6 +825,8 @@ async function main() {
|
|
|
822
825
|
caller: 'global',
|
|
823
826
|
});
|
|
824
827
|
globalThis.__agentVerboseFetchInstalled = true;
|
|
828
|
+
// Register handler to warn about pending stream logs at process exit (#231)
|
|
829
|
+
registerPendingStreamLogExitHandler();
|
|
825
830
|
}
|
|
826
831
|
})
|
|
827
832
|
.fail((msg, err, yargs) => {
|
package/src/provider/provider.ts
CHANGED
|
@@ -1623,35 +1623,30 @@ export namespace Provider {
|
|
|
1623
1623
|
}
|
|
1624
1624
|
|
|
1625
1625
|
if (!isSyntheticProvider && !info) {
|
|
1626
|
-
//
|
|
1627
|
-
//
|
|
1626
|
+
// Model not found even after cache refresh — fail with a clear error (#231)
|
|
1627
|
+
// Previously this created synthetic fallback info, which allowed the API call
|
|
1628
|
+
// to proceed with the wrong model (e.g., kimi-k2.5-free routed to minimax-m2.5-free)
|
|
1628
1629
|
const availableInProvider = Object.keys(provider.info.models).slice(
|
|
1629
1630
|
0,
|
|
1630
1631
|
10
|
|
1631
1632
|
);
|
|
1632
|
-
log.
|
|
1633
|
+
log.error(() => ({
|
|
1633
1634
|
message:
|
|
1634
|
-
'model not in provider catalog after refresh
|
|
1635
|
+
'model not found in provider catalog after refresh — refusing to proceed',
|
|
1635
1636
|
providerID,
|
|
1636
1637
|
modelID,
|
|
1637
1638
|
availableModels: availableInProvider,
|
|
1638
1639
|
totalModels: Object.keys(provider.info.models).length,
|
|
1639
1640
|
}));
|
|
1640
1641
|
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
temperature: true,
|
|
1650
|
-
tool_call: true,
|
|
1651
|
-
cost: { input: 0, output: 0 },
|
|
1652
|
-
limit: { context: 128000, output: 16384 },
|
|
1653
|
-
options: {},
|
|
1654
|
-
} as ModelsDev.Model;
|
|
1642
|
+
throw new ModelNotFoundError({
|
|
1643
|
+
providerID,
|
|
1644
|
+
modelID,
|
|
1645
|
+
suggestion:
|
|
1646
|
+
`Model "${modelID}" not found in provider "${providerID}" (checked ${Object.keys(provider.info.models).length} models). ` +
|
|
1647
|
+
`Available models include: ${availableInProvider.join(', ')}. ` +
|
|
1648
|
+
`Use --model ${providerID}/<model-id> with a valid model.`,
|
|
1649
|
+
});
|
|
1655
1650
|
}
|
|
1656
1651
|
|
|
1657
1652
|
try {
|
|
@@ -2,10 +2,11 @@ import { Log } from '../util/log';
|
|
|
2
2
|
import { config } from '../config/config';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* Custom fetch wrapper that handles rate limits (HTTP 429)
|
|
5
|
+
* Custom fetch wrapper that handles rate limits (HTTP 429) and server errors (HTTP 5xx)
|
|
6
|
+
* using time-based retry logic.
|
|
6
7
|
*
|
|
7
|
-
* This wrapper intercepts 429 responses at the HTTP level before the AI SDK's
|
|
8
|
-
* retry mechanism can interfere. It respects:
|
|
8
|
+
* This wrapper intercepts 429 and 5xx responses at the HTTP level before the AI SDK's
|
|
9
|
+
* internal retry mechanism can interfere. It respects:
|
|
9
10
|
* - retry-after headers (both seconds and HTTP date formats)
|
|
10
11
|
* - retry-after-ms header for millisecond precision
|
|
11
12
|
* - LINK_ASSISTANT_AGENT_RETRY_TIMEOUT for global time-based retry limit
|
|
@@ -15,10 +16,12 @@ import { config } from '../config/config';
|
|
|
15
16
|
* The AI SDK's internal retry uses a fixed count (default 3 attempts) and ignores
|
|
16
17
|
* retry-after headers. When providers return long retry-after values (e.g., 64 minutes),
|
|
17
18
|
* the SDK exhausts its retries before the agent can properly wait.
|
|
19
|
+
* Additionally, server errors (500, 502, 503) from providers like OpenCode API were not
|
|
20
|
+
* retried, causing compaction cycles to be lost silently.
|
|
18
21
|
*
|
|
19
22
|
* Solution:
|
|
20
|
-
* By wrapping fetch, we handle rate limits at the HTTP layer with
|
|
21
|
-
* ensuring the agent's 7-week global timeout is respected.
|
|
23
|
+
* By wrapping fetch, we handle rate limits and server errors at the HTTP layer with
|
|
24
|
+
* time-based retries, ensuring the agent's 7-week global timeout is respected.
|
|
22
25
|
*
|
|
23
26
|
* Important: Rate limit waits use ISOLATED AbortControllers that are NOT subject to
|
|
24
27
|
* provider/stream timeouts. This prevents long rate limit waits (e.g., 15 hours) from
|
|
@@ -26,6 +29,7 @@ import { config } from '../config/config';
|
|
|
26
29
|
*
|
|
27
30
|
* @see https://github.com/link-assistant/agent/issues/167
|
|
28
31
|
* @see https://github.com/link-assistant/agent/issues/183
|
|
32
|
+
* @see https://github.com/link-assistant/agent/issues/231
|
|
29
33
|
* @see https://github.com/vercel/ai/issues/12585
|
|
30
34
|
*/
|
|
31
35
|
|
|
@@ -37,6 +41,20 @@ export namespace RetryFetch {
|
|
|
37
41
|
const RETRY_BACKOFF_FACTOR = 2;
|
|
38
42
|
const RETRY_MAX_DELAY_NO_HEADERS = 30_000;
|
|
39
43
|
|
|
44
|
+
// Maximum number of retries for server errors (5xx) — unlike rate limits (429)
|
|
45
|
+
// which retry indefinitely within the global timeout, server errors use a fixed
|
|
46
|
+
// retry count to avoid retrying permanently broken endpoints (#231)
|
|
47
|
+
const SERVER_ERROR_MAX_RETRIES = 3;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Check if an HTTP status code is a retryable server error.
|
|
51
|
+
* Retries on 500 (Internal Server Error), 502 (Bad Gateway), and 503 (Service Unavailable).
|
|
52
|
+
* @see https://github.com/link-assistant/agent/issues/231
|
|
53
|
+
*/
|
|
54
|
+
function isRetryableServerError(status: number): boolean {
|
|
55
|
+
return status === 500 || status === 502 || status === 503;
|
|
56
|
+
}
|
|
57
|
+
|
|
40
58
|
// Minimum retry interval to prevent rapid retries (default: 30 seconds)
|
|
41
59
|
// Can be configured via AGENT_MIN_RETRY_INTERVAL env var
|
|
42
60
|
function getMinRetryInterval(): number {
|
|
@@ -298,19 +316,20 @@ export namespace RetryFetch {
|
|
|
298
316
|
};
|
|
299
317
|
|
|
300
318
|
/**
|
|
301
|
-
* Create a fetch function that handles rate limits with
|
|
319
|
+
* Create a fetch function that handles rate limits and server errors with retry logic.
|
|
302
320
|
*
|
|
303
321
|
* This wrapper:
|
|
304
|
-
* 1. Intercepts HTTP 429 responses
|
|
305
|
-
* 2.
|
|
306
|
-
* 3.
|
|
307
|
-
* 4.
|
|
322
|
+
* 1. Intercepts HTTP 429 (rate limit) responses — retries with retry-after headers
|
|
323
|
+
* 2. Intercepts HTTP 500/502/503 (server error) responses — retries up to SERVER_ERROR_MAX_RETRIES
|
|
324
|
+
* 3. Parses retry-after headers for 429 responses
|
|
325
|
+
* 4. Uses exponential backoff for server errors and network errors
|
|
326
|
+
* 5. Respects global LINK_ASSISTANT_AGENT_RETRY_TIMEOUT for all retries
|
|
308
327
|
*
|
|
309
328
|
* If retry-after exceeds LINK_ASSISTANT_AGENT_RETRY_TIMEOUT, the original 429 response is returned
|
|
310
329
|
* to let higher-level error handling take over.
|
|
311
330
|
*
|
|
312
331
|
* @param options Configuration options
|
|
313
|
-
* @returns A fetch function with rate limit retry handling
|
|
332
|
+
* @returns A fetch function with rate limit and server error retry handling
|
|
314
333
|
*/
|
|
315
334
|
export function create(options: RetryFetchOptions = {}): typeof fetch {
|
|
316
335
|
const baseFetch = options.baseFetch ?? fetch;
|
|
@@ -365,7 +384,74 @@ export namespace RetryFetch {
|
|
|
365
384
|
throw error;
|
|
366
385
|
}
|
|
367
386
|
|
|
368
|
-
//
|
|
387
|
+
// Handle retryable server errors (500, 502, 503) with limited retries (#231)
|
|
388
|
+
// Unlike rate limits (429) which retry indefinitely within timeout,
|
|
389
|
+
// server errors use a fixed count to avoid retrying broken endpoints.
|
|
390
|
+
if (isRetryableServerError(response.status)) {
|
|
391
|
+
if (attempt > SERVER_ERROR_MAX_RETRIES) {
|
|
392
|
+
// Read response body for diagnostics before returning (#231)
|
|
393
|
+
// This ensures the actual server error is visible in logs,
|
|
394
|
+
// preventing misleading downstream errors like "input_tokens undefined"
|
|
395
|
+
let errorBody = '';
|
|
396
|
+
try {
|
|
397
|
+
errorBody = await response.clone().text();
|
|
398
|
+
} catch {
|
|
399
|
+
errorBody = '<failed to read response body>';
|
|
400
|
+
}
|
|
401
|
+
log.warn(() => ({
|
|
402
|
+
message:
|
|
403
|
+
'server error max retries exceeded, returning error response',
|
|
404
|
+
sessionID,
|
|
405
|
+
status: response.status,
|
|
406
|
+
attempt,
|
|
407
|
+
maxRetries: SERVER_ERROR_MAX_RETRIES,
|
|
408
|
+
responseBody: errorBody.slice(0, 500),
|
|
409
|
+
}));
|
|
410
|
+
return response;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const elapsed = Date.now() - startTime;
|
|
414
|
+
if (elapsed >= maxRetryTimeout) {
|
|
415
|
+
let errorBody = '';
|
|
416
|
+
try {
|
|
417
|
+
errorBody = await response.clone().text();
|
|
418
|
+
} catch {
|
|
419
|
+
errorBody = '<failed to read response body>';
|
|
420
|
+
}
|
|
421
|
+
log.warn(() => ({
|
|
422
|
+
message:
|
|
423
|
+
'retry timeout exceeded for server error, returning error response',
|
|
424
|
+
sessionID,
|
|
425
|
+
status: response.status,
|
|
426
|
+
elapsedMs: elapsed,
|
|
427
|
+
maxRetryTimeoutMs: maxRetryTimeout,
|
|
428
|
+
responseBody: errorBody.slice(0, 500),
|
|
429
|
+
}));
|
|
430
|
+
return response;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Use exponential backoff for server errors (no retry-after expected)
|
|
434
|
+
const delay = addJitter(
|
|
435
|
+
Math.min(
|
|
436
|
+
RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1),
|
|
437
|
+
Math.min(maxBackoffDelay, RETRY_MAX_DELAY_NO_HEADERS)
|
|
438
|
+
)
|
|
439
|
+
);
|
|
440
|
+
|
|
441
|
+
log.info(() => ({
|
|
442
|
+
message: 'server error, will retry',
|
|
443
|
+
sessionID,
|
|
444
|
+
status: response.status,
|
|
445
|
+
attempt,
|
|
446
|
+
maxRetries: SERVER_ERROR_MAX_RETRIES,
|
|
447
|
+
delayMs: delay,
|
|
448
|
+
}));
|
|
449
|
+
|
|
450
|
+
await sleep(delay, init?.signal ?? undefined);
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Only handle rate limit errors (429) beyond this point
|
|
369
455
|
if (response.status !== 429) {
|
|
370
456
|
return response;
|
|
371
457
|
}
|
package/src/storage/storage.ts
CHANGED
|
@@ -180,8 +180,19 @@ export namespace Storage {
|
|
|
180
180
|
for (let index = migration; index < MIGRATIONS.length; index++) {
|
|
181
181
|
log.info(() => ({ message: 'running migration', index }));
|
|
182
182
|
const migration = MIGRATIONS[index];
|
|
183
|
-
await migration(dir).catch(() =>
|
|
184
|
-
log.error(() => ({
|
|
183
|
+
await migration(dir).catch((migrationError) =>
|
|
184
|
+
log.error(() => ({
|
|
185
|
+
message: 'failed to run migration',
|
|
186
|
+
index,
|
|
187
|
+
error:
|
|
188
|
+
migrationError instanceof Error
|
|
189
|
+
? {
|
|
190
|
+
name: migrationError.name,
|
|
191
|
+
message: migrationError.message,
|
|
192
|
+
stack: migrationError.stack,
|
|
193
|
+
}
|
|
194
|
+
: String(migrationError),
|
|
195
|
+
}))
|
|
185
196
|
);
|
|
186
197
|
await Bun.write(path.join(dir, 'migration'), (index + 1).toString());
|
|
187
198
|
}
|
|
@@ -24,6 +24,21 @@ const log = Log.create({ service: 'http' });
|
|
|
24
24
|
/** Global call counter shared across all verbose fetch wrappers */
|
|
25
25
|
let globalHttpCallCount = 0;
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Track pending async stream log operations (#231).
|
|
29
|
+
* When the process exits while stream logging is in progress, we log a warning
|
|
30
|
+
* so missing HTTP response bodies are visible in the logs rather than silently lost.
|
|
31
|
+
*/
|
|
32
|
+
let pendingStreamLogs = 0;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Get the current count of pending stream log operations.
|
|
36
|
+
* Useful for diagnostics and testing.
|
|
37
|
+
*/
|
|
38
|
+
export function getPendingStreamLogCount(): number {
|
|
39
|
+
return pendingStreamLogs;
|
|
40
|
+
}
|
|
41
|
+
|
|
27
42
|
/**
|
|
28
43
|
* Sanitize HTTP headers by masking sensitive values.
|
|
29
44
|
* Masks authorization, x-api-key, and api-key headers.
|
|
@@ -196,7 +211,8 @@ export function createVerboseFetch(
|
|
|
196
211
|
if (isStreaming) {
|
|
197
212
|
const [sdkStream, logStream] = response.body.tee();
|
|
198
213
|
|
|
199
|
-
// Consume log stream asynchronously
|
|
214
|
+
// Consume log stream asynchronously, tracking pending operations (#231)
|
|
215
|
+
pendingStreamLogs++;
|
|
200
216
|
(async () => {
|
|
201
217
|
try {
|
|
202
218
|
const reader = logStream.getReader();
|
|
@@ -225,6 +241,8 @@ export function createVerboseFetch(
|
|
|
225
241
|
});
|
|
226
242
|
} catch {
|
|
227
243
|
// Ignore logging errors
|
|
244
|
+
} finally {
|
|
245
|
+
pendingStreamLogs--;
|
|
228
246
|
}
|
|
229
247
|
})();
|
|
230
248
|
|
|
@@ -304,3 +322,18 @@ export function getHttpCallCount(): number {
|
|
|
304
322
|
export function resetHttpCallCount(): void {
|
|
305
323
|
globalHttpCallCount = 0;
|
|
306
324
|
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Register a process exit handler that warns about pending stream log operations.
|
|
328
|
+
* Call this once at startup when verbose mode is enabled (#231).
|
|
329
|
+
*/
|
|
330
|
+
export function registerPendingStreamLogExitHandler(): void {
|
|
331
|
+
process.once('exit', () => {
|
|
332
|
+
if (pendingStreamLogs > 0) {
|
|
333
|
+
// Use stderr directly since the process is exiting and log infrastructure may be unavailable
|
|
334
|
+
process.stderr.write(
|
|
335
|
+
`[verbose] warning: ${pendingStreamLogs} HTTP stream response log(s) were still pending at process exit — response bodies may be missing from logs\n`
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
}
|