@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/agent",
3
- "version": "0.19.0",
3
+ "version": "0.19.2",
4
4
  "description": "A minimal, public domain AI CLI agent compatible with OpenCode's JSON interface. Bun-only runtime.",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -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 status === 429 || status === 503;
1548
+ return (
1549
+ status === 429 || status === 500 || status === 502 || status === 503
1550
+ );
1548
1551
  };
1549
1552
 
1550
1553
  /**
@@ -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
- // Without this check, a non-existent model silently proceeds and fails at API call time
73
- // with confusing "reason: unknown" and zero tokens
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 - warn and suggest alternatives
80
- const availableModels = Object.keys(provider.info.models).slice(0, 5);
81
- Log.Default.warn(() => ({
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 - will attempt anyway (provider may support unlisted models)',
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
- // Don't fail on validation errors - the model may still work
91
- // This is a best-effort check
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: 'skipping model existence validation',
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 { createVerboseFetch } from './util/verbose-fetch.ts';
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) => {
@@ -1623,35 +1623,30 @@ export namespace Provider {
1623
1623
  }
1624
1624
 
1625
1625
  if (!isSyntheticProvider && !info) {
1626
- // Still not found after refresh - create fallback info and try anyway
1627
- // Provider may support unlisted models
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.warn(() => ({
1633
+ log.error(() => ({
1633
1634
  message:
1634
- 'model not in provider catalog after refresh - attempting anyway (may be unlisted)',
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
- // Create a minimal fallback model info so SDK loading can proceed
1642
- // Use sensible defaults - the provider will reject if the model truly doesn't exist
1643
- info = {
1644
- id: modelID,
1645
- name: modelID,
1646
- release_date: '',
1647
- attachment: false,
1648
- reasoning: false,
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) using time-based retry logic.
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 internal
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 time-based retries,
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 time-based retry logic.
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. Parses retry-after headers
306
- * 3. Waits for the specified duration (respecting global timeout)
307
- * 4. Retries the request
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
- // Only handle rate limit errors (429)
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
  }
@@ -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(() => ({ message: 'failed to run migration', index }))
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
+ }