@link-assistant/agent 0.18.3 → 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.
@@ -1,10 +1,10 @@
1
1
  import z from 'zod';
2
2
  import { Bus } from '../bus';
3
- import { Flag } from '../flag/flag';
3
+ import { config } from '../config/config';
4
4
  import { Instance } from '../project/instance';
5
5
  import { Log } from '../util/log';
6
6
  import { FileIgnore } from './ignore';
7
- import { Config } from '../config/config';
7
+ import { Config } from '../config/file-config';
8
8
  // @ts-ignore
9
9
  import { createWrapper } from '@parcel/watcher/wrapper';
10
10
  import { lazy } from '../util/lazy';
@@ -83,7 +83,7 @@ export namespace FileWatcher {
83
83
  );
84
84
 
85
85
  export function init() {
86
- if (!Flag.OPENCODE_EXPERIMENTAL_WATCHER) return;
86
+ if (!config.experimentalWatcher) return;
87
87
  state();
88
88
  }
89
89
  }
@@ -5,7 +5,7 @@ import path from 'path';
5
5
  import z from 'zod';
6
6
 
7
7
  import * as Formatter from './formatter';
8
- import { Config } from '../config/config';
8
+ import { Config } from '../config/file-config';
9
9
  import { mergeDeep } from 'remeda';
10
10
  import { Instance } from '../project/instance';
11
11
 
package/src/index.js CHANGED
@@ -1,5 +1,11 @@
1
1
  #!/usr/bin/env bun
2
- import { Flag } from './flag/flag.ts';
2
+ import {
3
+ config,
4
+ initConfig,
5
+ isVerbose,
6
+ setVerbose,
7
+ getConfigSnapshot,
8
+ } from './config/config.ts';
3
9
  import { setProcessName } from './cli/process-name.ts';
4
10
  setProcessName('agent');
5
11
  import { Server } from './server/server.ts';
@@ -21,7 +27,10 @@ import { McpCommand } from './cli/cmd/mcp.ts';
21
27
  import { AuthCommand } from './cli/cmd/auth.ts';
22
28
  import { FormatError } from './cli/error.ts';
23
29
  import { UI } from './cli/ui.ts';
24
- import { createVerboseFetch } from './util/verbose-fetch.ts';
30
+ import {
31
+ createVerboseFetch,
32
+ registerPendingStreamLogExitHandler,
33
+ } from './util/verbose-fetch.ts';
25
34
  import {
26
35
  runContinuousServerMode,
27
36
  runContinuousDirectMode,
@@ -249,7 +258,7 @@ async function runAgentMode(argv, request) {
249
258
  workingDirectory: process.cwd(),
250
259
  scriptPath: import.meta.path,
251
260
  }));
252
- if (Flag.OPENCODE_DRY_RUN) {
261
+ if (config.dryRun) {
253
262
  Log.Default.info(() => ({
254
263
  message: 'Dry run mode enabled',
255
264
  mode: 'dry-run',
@@ -336,7 +345,7 @@ async function runContinuousAgentMode(argv) {
336
345
  workingDirectory: process.cwd(),
337
346
  scriptPath: import.meta.path,
338
347
  }));
339
- if (Flag.OPENCODE_DRY_RUN) {
348
+ if (config.dryRun) {
340
349
  Log.Default.info(() => ({
341
350
  message: 'Dry run mode enabled',
342
351
  mode: 'dry-run',
@@ -608,7 +617,7 @@ async function main() {
608
617
  handler: async (argv) => {
609
618
  // Check both CLI flag and environment variable for compact JSON mode
610
619
  const compactJson =
611
- argv['compact-json'] === true || Flag.COMPACT_JSON();
620
+ argv['compact-json'] === true || config.compactJson;
612
621
 
613
622
  // Check if --prompt flag was provided
614
623
  if (argv.prompt) {
@@ -767,54 +776,57 @@ async function main() {
767
776
  await runAgentMode(argv, request);
768
777
  },
769
778
  })
770
- // Initialize logging and flags early for all CLI commands
779
+ // Initialize centralized config and flags from CLI args + env vars + .lenv.
780
+ // Uses lino-arguments getenv() for env var resolution (case-insensitive,
781
+ // type-preserving, .lenv support).
782
+ // See: https://github.com/link-foundation/lino-arguments
783
+ // See: https://github.com/link-assistant/agent/issues/227
771
784
  .middleware(async (argv) => {
772
- const isCompact = argv['compact-json'] === true || Flag.COMPACT_JSON();
785
+ // Initialize global config using makeConfig from lino-arguments.
786
+ // Resolves CLI args + env vars + .lenv files in one place.
787
+ // After this call, the global `config` variable is fully resolved.
788
+ // See: https://github.com/link-foundation/lino-arguments
789
+ initConfig();
790
+
791
+ // Override compact-json from argv if explicitly set.
792
+ if (argv['compact-json'] === true) {
793
+ config.compactJson = true;
794
+ }
795
+ const isCompact = config.compactJson;
773
796
  if (isCompact) {
774
797
  setCompactJson(true);
775
798
  }
776
- if (argv.verbose) {
777
- Flag.setVerbose(true);
778
- }
779
- if (argv['dry-run']) {
780
- Flag.setDryRun(true);
781
- }
782
- if (argv['generate-title'] === true) {
783
- Flag.setGenerateTitle(true);
784
- }
785
- // output-response-model is enabled by default, only set if explicitly disabled
786
- if (argv['output-response-model'] === false) {
787
- Flag.setOutputResponseModel(false);
788
- }
789
- // summarize-session is enabled by default, only set if explicitly disabled
790
- if (argv['summarize-session'] === false) {
791
- Flag.setSummarizeSession(false);
792
- } else {
793
- Flag.setSummarizeSession(true);
794
- }
795
- // retry-on-rate-limits is enabled by default, only set if explicitly disabled
796
- if (argv['retry-on-rate-limits'] === false) {
797
- Flag.setRetryOnRateLimits(false);
799
+
800
+ // Sync verbose to env var for subprocess resilience.
801
+ if (config.verbose) {
802
+ setVerbose(true);
798
803
  }
804
+
805
+ // Initialize logging.
799
806
  await Log.init({
800
- print: Flag.OPENCODE_VERBOSE,
801
- level: Flag.OPENCODE_VERBOSE ? 'DEBUG' : 'INFO',
807
+ print: isVerbose(),
808
+ level: isVerbose() ? 'DEBUG' : 'INFO',
802
809
  compactJson: isCompact,
803
810
  });
804
811
 
812
+ // Always log the resolved configuration as JSON.
813
+ // This is critical for debugging — shows exactly what config was resolved
814
+ // from CLI args, env vars, and .lenv files combined.
815
+ Log.Default.info(() => ({
816
+ type: 'config',
817
+ message: 'Agent configuration resolved',
818
+ source: 'lino-arguments (CLI args > env vars > .lenv > defaults)',
819
+ config: getConfigSnapshot(),
820
+ }));
821
+
805
822
  // Global fetch monkey-patch for verbose HTTP logging (#221).
806
- // This catches any HTTP calls that go through globalThis.fetch directly,
807
- // including non-provider calls (auth, config, tools) that may not have
808
- // their own createVerboseFetch wrapper. The provider-level wrapper in
809
- // provider.ts getSDK() also logs independently — both mechanisms are
810
- // kept active to maximize HTTP observability in --verbose mode.
811
- // See: https://github.com/link-assistant/agent/issues/221
812
- // See: https://github.com/link-assistant/agent/issues/217
813
823
  if (!globalThis.__agentVerboseFetchInstalled) {
814
824
  globalThis.fetch = createVerboseFetch(globalThis.fetch, {
815
825
  caller: 'global',
816
826
  });
817
827
  globalThis.__agentVerboseFetchInstalled = true;
828
+ // Register handler to warn about pending stream logs at process exit (#231)
829
+ registerPendingStreamLogExitHandler();
818
830
  }
819
831
  })
820
832
  .fail((msg, err, yargs) => {
@@ -6,11 +6,11 @@
6
6
  * - claude: Claude CLI stream-json format - NDJSON (newline-delimited JSON)
7
7
  *
8
8
  * Output goes to stdout for normal messages, stderr for errors.
9
- * Use AGENT_CLI_COMPACT env var or --compact-json flag for NDJSON output.
9
+ * Use LINK_ASSISTANT_AGENT_COMPACT_JSON env var or --compact-json flag for NDJSON output.
10
10
  */
11
11
 
12
12
  import { EOL } from 'os';
13
- import { Flag } from '../flag/flag';
13
+ import { config } from '../config/config';
14
14
 
15
15
  export type JsonStandard = 'opencode' | 'claude';
16
16
 
@@ -50,7 +50,7 @@ export interface ClaudeEvent {
50
50
 
51
51
  /**
52
52
  * Serialize JSON output based on the selected standard
53
- * Respects AGENT_CLI_COMPACT env var for OpenCode format
53
+ * Respects LINK_ASSISTANT_AGENT_COMPACT_JSON env var for OpenCode format
54
54
  */
55
55
  export function serializeOutput(
56
56
  event: OpenCodeEvent | ClaudeEvent,
@@ -60,8 +60,8 @@ export function serializeOutput(
60
60
  // NDJSON format - always compact, one line
61
61
  return JSON.stringify(event) + EOL;
62
62
  }
63
- // OpenCode format - compact if AGENT_CLI_COMPACT is set
64
- if (Flag.COMPACT_JSON()) {
63
+ // OpenCode format - compact if LINK_ASSISTANT_AGENT_COMPACT_JSON is set
64
+ if (config.compactJson) {
65
65
  return JSON.stringify(event) + EOL;
66
66
  }
67
67
  return JSON.stringify(event, null, 2) + EOL;
package/src/mcp/index.ts CHANGED
@@ -3,7 +3,8 @@ import { type Tool } from 'ai';
3
3
  import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
4
4
  import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
5
5
  import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
6
- import { Config } from '../config/config';
6
+ import { Config } from '../config/file-config';
7
+ import { config } from '../config/config';
7
8
  import { Log } from '../util/log';
8
9
  import { NamedError } from '../util/error';
9
10
  import z from 'zod/v4';
@@ -89,23 +90,15 @@ export namespace MCP {
89
90
  const status: Record<string, Status> = {};
90
91
  const timeoutConfigs: Record<string, TimeoutConfig> = {};
91
92
 
92
- // Determine global timeout defaults from config and environment variables
93
- const envDefaultTimeout = process.env.MCP_DEFAULT_TOOL_CALL_TIMEOUT
94
- ? parseInt(process.env.MCP_DEFAULT_TOOL_CALL_TIMEOUT, 10)
95
- : undefined;
96
- const envMaxTimeout = process.env.MCP_MAX_TOOL_CALL_TIMEOUT
97
- ? parseInt(process.env.MCP_MAX_TOOL_CALL_TIMEOUT, 10)
98
- : undefined;
99
-
93
+ // Determine global timeout defaults from config and environment variables.
94
+ // Uses config.mcp*() which reads from centralized AgentConfig (lino-arguments).
100
95
  const globalDefaults: GlobalTimeoutDefaults = {
101
96
  defaultTimeout:
102
97
  cfg.mcp_defaults?.tool_call_timeout ??
103
- envDefaultTimeout ??
104
- BUILTIN_DEFAULT_TOOL_CALL_TIMEOUT,
98
+ config.mcpDefaultToolCallTimeout,
105
99
  maxTimeout:
106
100
  cfg.mcp_defaults?.max_tool_call_timeout ??
107
- envMaxTimeout ??
108
- BUILTIN_MAX_TOOL_CALL_TIMEOUT,
101
+ config.mcpMaxToolCallTimeout,
109
102
  };
110
103
 
111
104
  await Promise.all(
@@ -1,7 +1,6 @@
1
1
  import { Format } from '../format';
2
2
  import { FileWatcher } from '../file/watcher';
3
3
  import { File } from '../file';
4
- import { Flag } from '../flag/flag';
5
4
  import { Project } from './project';
6
5
  import { Bus } from '../bus';
7
6
  import { Command } from '../command';
@@ -4,7 +4,6 @@ import path from 'path';
4
4
  import { $ } from 'bun';
5
5
  import { Storage } from '../storage/storage';
6
6
  import { Log } from '../util/log';
7
- import { Flag } from '../flag/flag';
8
7
 
9
8
  export namespace Project {
10
9
  const log = Log.create({ service: 'project' });
@@ -1,6 +1,6 @@
1
1
  import z from 'zod';
2
2
  import path from 'path';
3
- import { Config } from '../config/config';
3
+ import { Config } from '../config/file-config';
4
4
  import { mergeDeep, sortBy } from 'remeda';
5
5
  import { NoSuchModelError, type LanguageModel, type Provider as SDK } from 'ai';
6
6
  import { Log } from '../util/log';
@@ -12,7 +12,7 @@ import { ClaudeOAuth } from '../auth/claude-oauth';
12
12
  import { AuthPlugins } from '../auth/plugins';
13
13
  import { Instance } from '../project/instance';
14
14
  import { Global } from '../global';
15
- import { Flag } from '../flag/flag';
15
+ import { config, isVerbose } from '../config/config';
16
16
  import { iife } from '../util/iife';
17
17
  import { createEchoModel } from './echo';
18
18
  import { createCacheModel } from './cache';
@@ -647,7 +647,7 @@ export namespace Provider {
647
647
  'link-assistant': async () => {
648
648
  // Echo provider is always available - no external dependencies needed
649
649
  return {
650
- autoload: Flag.OPENCODE_DRY_RUN, // Auto-load only in dry-run mode
650
+ autoload: config.dryRun, // Auto-load only in dry-run mode
651
651
  async getModel(_sdk: any, modelID: string) {
652
652
  // Return our custom echo model that implements LanguageModelV1
653
653
  return createEchoModel(modelID);
@@ -1124,7 +1124,7 @@ export namespace Provider {
1124
1124
  .filter(
1125
1125
  ([, model]) =>
1126
1126
  ((!model.experimental && model.status !== 'alpha') ||
1127
- Flag.OPENCODE_ENABLE_EXPERIMENTAL_MODELS) &&
1127
+ config.enableExperimentalModels) &&
1128
1128
  model.status !== 'deprecated'
1129
1129
  )
1130
1130
  );
@@ -1220,7 +1220,7 @@ export namespace Provider {
1220
1220
  pkg,
1221
1221
  globalVerboseFetchInstalled:
1222
1222
  !!globalThis.__agentVerboseFetchInstalled,
1223
- verboseAtCreation: Flag.OPENCODE_VERBOSE,
1223
+ verboseAtCreation: isVerbose(),
1224
1224
  });
1225
1225
 
1226
1226
  options['fetch'] = async (
@@ -1228,9 +1228,11 @@ export namespace Provider {
1228
1228
  init?: RequestInit
1229
1229
  ): Promise<Response> => {
1230
1230
  // Check verbose flag at call time — not at SDK creation time.
1231
- // This ensures --verbose works even when the flag is set after SDK creation.
1231
+ // Uses isVerbose() with env var fallback for resilience against
1232
+ // flag state loss in subprocess/module-reload scenarios.
1232
1233
  // See: https://github.com/link-assistant/agent/issues/206
1233
- if (!Flag.OPENCODE_VERBOSE) {
1234
+ // See: https://github.com/link-assistant/agent/issues/227
1235
+ if (!isVerbose()) {
1234
1236
  return innerFetch(input, init);
1235
1237
  }
1236
1238
 
@@ -1621,35 +1623,30 @@ export namespace Provider {
1621
1623
  }
1622
1624
 
1623
1625
  if (!isSyntheticProvider && !info) {
1624
- // Still not found after refresh - create fallback info and try anyway
1625
- // 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)
1626
1629
  const availableInProvider = Object.keys(provider.info.models).slice(
1627
1630
  0,
1628
1631
  10
1629
1632
  );
1630
- log.warn(() => ({
1633
+ log.error(() => ({
1631
1634
  message:
1632
- 'model not in provider catalog after refresh - attempting anyway (may be unlisted)',
1635
+ 'model not found in provider catalog after refresh refusing to proceed',
1633
1636
  providerID,
1634
1637
  modelID,
1635
1638
  availableModels: availableInProvider,
1636
1639
  totalModels: Object.keys(provider.info.models).length,
1637
1640
  }));
1638
1641
 
1639
- // Create a minimal fallback model info so SDK loading can proceed
1640
- // Use sensible defaults - the provider will reject if the model truly doesn't exist
1641
- info = {
1642
- id: modelID,
1643
- name: modelID,
1644
- release_date: '',
1645
- attachment: false,
1646
- reasoning: false,
1647
- temperature: true,
1648
- tool_call: true,
1649
- cost: { input: 0, output: 0 },
1650
- limit: { context: 128000, output: 16384 },
1651
- options: {},
1652
- } 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
+ });
1653
1650
  }
1654
1651
 
1655
1652
  try {
@@ -1787,7 +1784,7 @@ export namespace Provider {
1787
1784
  // In dry-run mode, use the echo provider by default
1788
1785
  // This allows testing round-trips and multi-turn conversations without API costs
1789
1786
  // @see https://github.com/link-assistant/agent/issues/89
1790
- if (Flag.OPENCODE_DRY_RUN) {
1787
+ if (config.dryRun) {
1791
1788
  log.info('dry-run mode enabled, using echo provider as default');
1792
1789
  return {
1793
1790
  providerID: 'link-assistant',
@@ -1,24 +1,27 @@
1
1
  import { Log } from '../util/log';
2
- import { Flag } from '../flag/flag';
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
- * - AGENT_RETRY_TIMEOUT for global time-based retry limit
12
- * - AGENT_MAX_RETRY_DELAY for maximum single retry wait time
12
+ * - LINK_ASSISTANT_AGENT_RETRY_TIMEOUT for global time-based retry limit
13
+ * - LINK_ASSISTANT_AGENT_MAX_RETRY_DELAY for maximum single retry wait time
13
14
  *
14
15
  * Problem solved:
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 { Flag } from '../flag/flag';
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,10 +41,24 @@ 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 {
43
- return Flag.MIN_RETRY_INTERVAL();
61
+ return config.minRetryInterval * 1000;
44
62
  }
45
63
 
46
64
  /**
@@ -194,7 +212,7 @@ export namespace RetryFetch {
194
212
  *
195
213
  * This controller is NOT connected to the request's AbortSignal, so it won't be
196
214
  * affected by provider timeouts (default 5 minutes) or stream timeouts.
197
- * It only respects the global AGENT_RETRY_TIMEOUT.
215
+ * It only respects the global LINK_ASSISTANT_AGENT_RETRY_TIMEOUT.
198
216
  *
199
217
  * However, it DOES check the user's abort signal periodically (every 10 seconds)
200
218
  * to allow user cancellation during long rate limit waits.
@@ -217,7 +235,7 @@ export namespace RetryFetch {
217
235
  const controller = new AbortController();
218
236
  const timers: NodeJS.Timeout[] = [];
219
237
 
220
- // Set a timeout based on the global AGENT_RETRY_TIMEOUT (not provider timeout)
238
+ // Set a timeout based on the global LINK_ASSISTANT_AGENT_RETRY_TIMEOUT (not provider timeout)
221
239
  const globalTimeoutId = setTimeout(() => {
222
240
  controller.abort(
223
241
  new DOMException(
@@ -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
- * If retry-after exceeds AGENT_RETRY_TIMEOUT, the original 429 response is returned
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;
@@ -322,8 +341,8 @@ export namespace RetryFetch {
322
341
  ): Promise<Response> {
323
342
  let attempt = 0;
324
343
  const startTime = Date.now();
325
- const maxRetryTimeout = Flag.RETRY_TIMEOUT() * 1000;
326
- const maxBackoffDelay = Flag.MAX_RETRY_DELAY();
344
+ const maxRetryTimeout = config.retryTimeout * 1000;
345
+ const maxBackoffDelay = config.maxRetryDelay * 1000;
327
346
 
328
347
  while (true) {
329
348
  attempt++;
@@ -365,13 +384,80 @@ 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
  }
372
458
 
373
459
  // If retry on rate limits is disabled, return 429 immediately
374
- if (!Flag.RETRY_ON_RATE_LIMITS) {
460
+ if (!config.retryOnRateLimits) {
375
461
  log.info(() => ({
376
462
  message:
377
463
  'rate limit retry disabled (--no-retry-on-rate-limits), returning 429',
@@ -442,7 +528,7 @@ export namespace RetryFetch {
442
528
  // Wait before retrying using ISOLATED signal
443
529
  // This is critical for issue #183: Rate limit waits can be hours long (e.g., 15 hours),
444
530
  // but provider timeouts are typically 5 minutes. By using an isolated AbortController
445
- // that only respects AGENT_RETRY_TIMEOUT, we prevent the provider timeout from
531
+ // that only respects LINK_ASSISTANT_AGENT_RETRY_TIMEOUT, we prevent the provider timeout from
446
532
  // aborting long rate limit waits.
447
533
  //
448
534
  // The isolated signal periodically checks the user's abort signal (every 10 seconds)
@@ -211,8 +211,10 @@ export class Agent {
211
211
  ...data,
212
212
  };
213
213
  // Pretty-print JSON for human readability, compact for programmatic use
214
- // Use AGENT_CLI_COMPACT=1 for compact output (tests, automation)
215
- const compact = process.env.AGENT_CLI_COMPACT === '1';
214
+ // Use LINK_ASSISTANT_AGENT_COMPACT_JSON=1 for compact output (tests, automation)
215
+ const compact =
216
+ process.env.LINK_ASSISTANT_AGENT_COMPACT_JSON === 'true' ||
217
+ process.env.LINK_ASSISTANT_AGENT_COMPACT_JSON === '1';
216
218
  process.stdout.write(
217
219
  `${compact ? JSON.stringify(event) : JSON.stringify(event, null, 2)}\n`
218
220
  );
@@ -9,7 +9,7 @@ import { Bus } from '../bus';
9
9
  import z from 'zod';
10
10
  import type { ModelsDev } from '../provider/models';
11
11
  import { SessionPrompt } from './prompt';
12
- import { Flag } from '../flag/flag';
12
+ import { config, isVerbose } from '../config/config';
13
13
  import { Token } from '../util/token';
14
14
  import { Log } from '../util/log';
15
15
  import { ProviderTransform } from '../provider/transform';
@@ -98,7 +98,7 @@ export namespace SessionCompaction {
98
98
  compactionModel?: CompactionModelConfig;
99
99
  compactionModelContextLimit?: number;
100
100
  }) {
101
- if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) return false;
101
+ if (config.disableAutocompact) return false;
102
102
  const baseModelContextLimit = input.model.limit.context;
103
103
  if (baseModelContextLimit === 0) return false;
104
104
  const count =
@@ -180,7 +180,7 @@ export namespace SessionCompaction {
180
180
  // calls. then erases output of previous tool calls. idea is to throw away old
181
181
  // tool calls that are no longer relevant.
182
182
  export async function prune(input: { sessionID: string }) {
183
- if (Flag.OPENCODE_DISABLE_PRUNE) return;
183
+ if (config.disablePrune) return;
184
184
  log.info(() => ({ message: 'pruning' }));
185
185
  const msgs = await Session.messages({ sessionID: input.sessionID });
186
186
  let total = 0;
@@ -240,7 +240,7 @@ export namespace SessionCompaction {
240
240
  input.model.providerID,
241
241
  input.model.modelID
242
242
  );
243
- if (Flag.OPENCODE_VERBOSE) {
243
+ if (isVerbose()) {
244
244
  log.info(() => ({
245
245
  message: 'compaction model loaded',
246
246
  providerID: model.providerID,
@@ -303,7 +303,7 @@ export namespace SessionCompaction {
303
303
  // Defensive check: ensure modelMessages is iterable (AI SDK 6.0.1 compatibility fix)
304
304
  const safeModelMessages = Array.isArray(modelMessages) ? modelMessages : [];
305
305
 
306
- if (Flag.OPENCODE_VERBOSE) {
306
+ if (isVerbose()) {
307
307
  log.info(() => ({
308
308
  message: 'compaction streamText call',
309
309
  providerID: model.providerID,