@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.
- package/README.md +1 -1
- package/package.json +3 -2
- package/src/agent/agent.ts +1 -1
- package/src/auth/plugins.ts +4 -1
- package/src/bun/index.ts +2 -2
- package/src/cli/cmd/mcp.ts +1 -1
- package/src/cli/cmd/run.ts +1 -2
- package/src/cli/continuous-mode.js +3 -3
- package/src/cli/error.ts +1 -1
- package/src/cli/model-config.js +20 -10
- package/src/cli/output.ts +5 -5
- package/src/command/index.ts +1 -1
- package/src/config/config.ts +345 -1116
- package/src/config/file-config.ts +1146 -0
- package/src/file/watcher.ts +3 -3
- package/src/format/index.ts +1 -1
- package/src/index.js +50 -38
- package/src/json-standard/index.ts +5 -5
- package/src/mcp/index.ts +6 -13
- package/src/project/bootstrap.ts +0 -1
- package/src/project/project.ts +0 -1
- package/src/provider/provider.ts +23 -26
- package/src/provider/retry-fetch.ts +109 -23
- package/src/session/agent.js +4 -2
- package/src/session/compaction.ts +5 -5
- package/src/session/index.ts +19 -19
- package/src/session/processor.ts +4 -4
- package/src/session/prompt.ts +5 -5
- package/src/session/retry.ts +9 -9
- package/src/session/summary.ts +8 -8
- package/src/session/system.ts +1 -1
- package/src/snapshot/index.ts +1 -1
- package/src/storage/storage.ts +13 -2
- package/src/tool/read.ts +4 -3
- package/src/tool/registry.ts +1 -2
- package/src/tool/websearch.ts +1 -1
- package/src/util/log-lazy.ts +9 -11
- package/src/util/log.ts +9 -8
- package/src/util/verbose-fetch.ts +42 -6
- package/src/flag/flag.ts +0 -212
package/src/file/watcher.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import z from 'zod';
|
|
2
2
|
import { Bus } from '../bus';
|
|
3
|
-
import {
|
|
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 (!
|
|
86
|
+
if (!config.experimentalWatcher) return;
|
|
87
87
|
state();
|
|
88
88
|
}
|
|
89
89
|
}
|
package/src/format/index.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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 (
|
|
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 (
|
|
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 ||
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
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:
|
|
801
|
-
level:
|
|
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
|
|
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 {
|
|
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
|
|
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
|
|
64
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
BUILTIN_DEFAULT_TOOL_CALL_TIMEOUT,
|
|
98
|
+
config.mcpDefaultToolCallTimeout,
|
|
105
99
|
maxTimeout:
|
|
106
100
|
cfg.mcp_defaults?.max_tool_call_timeout ??
|
|
107
|
-
|
|
108
|
-
BUILTIN_MAX_TOOL_CALL_TIMEOUT,
|
|
101
|
+
config.mcpMaxToolCallTimeout,
|
|
109
102
|
};
|
|
110
103
|
|
|
111
104
|
await Promise.all(
|
package/src/project/bootstrap.ts
CHANGED
|
@@ -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';
|
package/src/project/project.ts
CHANGED
package/src/provider/provider.ts
CHANGED
|
@@ -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 {
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
1625
|
-
//
|
|
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.
|
|
1633
|
+
log.error(() => ({
|
|
1631
1634
|
message:
|
|
1632
|
-
'model not in provider catalog after refresh
|
|
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
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
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 (
|
|
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 {
|
|
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
|
-
* -
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
* If retry-after exceeds
|
|
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 =
|
|
326
|
-
const maxBackoffDelay =
|
|
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
|
-
//
|
|
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 (!
|
|
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
|
|
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)
|
package/src/session/agent.js
CHANGED
|
@@ -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
|
|
215
|
-
const compact =
|
|
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 {
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
306
|
+
if (isVerbose()) {
|
|
307
307
|
log.info(() => ({
|
|
308
308
|
message: 'compaction streamText call',
|
|
309
309
|
providerID: model.providerID,
|