@link-assistant/agent 0.25.6 → 0.25.8
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/cli/continuous-mode.js +10 -2
- package/src/cli/model-config.js +17 -11
- package/src/index.js +18 -5
- package/src/provider/retry-fetch.ts +7 -1
- package/src/session/message-v2.ts +5 -1
- package/src/session/prompt.ts +15 -0
- package/src/util/network-error.ts +56 -0
package/package.json
CHANGED
|
@@ -366,7 +366,7 @@ export async function runContinuousServerMode(
|
|
|
366
366
|
});
|
|
367
367
|
|
|
368
368
|
// Send message
|
|
369
|
-
fetch(
|
|
369
|
+
const requestPromise = fetch(
|
|
370
370
|
`http://${server.hostname}:${server.port}/session/${sessionID}/message`,
|
|
371
371
|
{
|
|
372
372
|
method: 'POST',
|
|
@@ -391,7 +391,10 @@ export async function runContinuousServerMode(
|
|
|
391
391
|
});
|
|
392
392
|
});
|
|
393
393
|
|
|
394
|
+
// See the direct-mode comment below: the request rejection must be
|
|
395
|
+
// observed before the process can exit (#290).
|
|
394
396
|
await messagePromise;
|
|
397
|
+
await requestPromise;
|
|
395
398
|
isProcessing = false;
|
|
396
399
|
|
|
397
400
|
// Process next pending message if any
|
|
@@ -586,7 +589,7 @@ export async function runContinuousDirectMode(
|
|
|
586
589
|
});
|
|
587
590
|
|
|
588
591
|
// Send message directly
|
|
589
|
-
SessionPrompt.prompt({
|
|
592
|
+
const promptPromise = SessionPrompt.prompt({
|
|
590
593
|
sessionID,
|
|
591
594
|
parts,
|
|
592
595
|
model: { providerID, modelID },
|
|
@@ -605,7 +608,12 @@ export async function runContinuousDirectMode(
|
|
|
605
608
|
});
|
|
606
609
|
});
|
|
607
610
|
|
|
611
|
+
// Await BOTH the idle event and the prompt promise. A fatal failure
|
|
612
|
+
// publishes `session.idle` while unwinding, so waiting only on the idle
|
|
613
|
+
// event let the process exit before the rejection handler above ran —
|
|
614
|
+
// no error record, exit code 0 (#290).
|
|
608
615
|
await messagePromise;
|
|
616
|
+
await promptPromise;
|
|
609
617
|
isProcessing = false;
|
|
610
618
|
|
|
611
619
|
// Process next pending message if any
|
package/src/cli/model-config.js
CHANGED
|
@@ -15,6 +15,17 @@ import {
|
|
|
15
15
|
getDefaultCompactionSafetyMarginPercent,
|
|
16
16
|
} from './defaults.ts';
|
|
17
17
|
|
|
18
|
+
export class ModelResolutionError extends Error {
|
|
19
|
+
constructor({ modelArgv, defaultModel }) {
|
|
20
|
+
super(
|
|
21
|
+
`Detected a --model flag that could not be parsed reliably from argv ${JSON.stringify(modelArgv)}. ` +
|
|
22
|
+
`Refusing to continue with default model "${defaultModel}". ` +
|
|
23
|
+
'Pass each option and value as a separate argv element, for example: ["--model", "provider/model", "--verbose"].'
|
|
24
|
+
);
|
|
25
|
+
this.name = 'ModelResolutionError';
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
18
29
|
/**
|
|
19
30
|
* Parse model config from argv. Supports "provider/model" or short "model" format.
|
|
20
31
|
* @param {object} argv - Parsed command line arguments
|
|
@@ -75,18 +86,13 @@ export async function parseModelConfig(
|
|
|
75
86
|
rawArgvStr.includes('-m ') ||
|
|
76
87
|
rawArgvStr.includes('-m=')
|
|
77
88
|
) {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
processArgv: process.argv,
|
|
84
|
-
bunArgv:
|
|
85
|
-
typeof globalThis.Bun !== 'undefined' && globalThis.Bun.argv
|
|
86
|
-
? globalThis.Bun.argv
|
|
87
|
-
: '(not available)',
|
|
89
|
+
const modelArgv = process.argv.filter(
|
|
90
|
+
(arg) => typeof arg === 'string' && /(^|\s)(--model|-m)(=|\s)/.test(arg)
|
|
91
|
+
);
|
|
92
|
+
throw new ModelResolutionError({
|
|
93
|
+
modelArgv: modelArgv.length > 0 ? modelArgv : process.argv,
|
|
88
94
|
defaultModel,
|
|
89
|
-
})
|
|
95
|
+
});
|
|
90
96
|
}
|
|
91
97
|
}
|
|
92
98
|
|
package/src/index.js
CHANGED
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
runContinuousServerMode,
|
|
37
37
|
runContinuousDirectMode,
|
|
38
38
|
resolveResumeSession,
|
|
39
|
+
getHasError,
|
|
39
40
|
} from './cli/continuous-mode.js';
|
|
40
41
|
import { parseStreamJsonInput } from './cli/input-queue.js';
|
|
41
42
|
import { createBusEventSubscription } from './cli/event-handler.js';
|
|
@@ -425,6 +426,12 @@ async function runContinuousAgentMode(argv) {
|
|
|
425
426
|
},
|
|
426
427
|
});
|
|
427
428
|
|
|
429
|
+
// Continuous mode tracks its own error state in its module scope — fold it
|
|
430
|
+
// in here, otherwise a fatal run always exits 0 (#290, recurrence of #22).
|
|
431
|
+
if (getHasError()) {
|
|
432
|
+
hasError = true;
|
|
433
|
+
}
|
|
434
|
+
|
|
428
435
|
// Explicitly exit to ensure process terminates (#213)
|
|
429
436
|
Log.Default.info(() => ({
|
|
430
437
|
message: 'Agent exiting',
|
|
@@ -497,8 +504,8 @@ async function runServerMode(
|
|
|
497
504
|
const message = request.message || 'hi';
|
|
498
505
|
const parts = [{ type: 'text', text: message }];
|
|
499
506
|
|
|
500
|
-
// Start the prompt (don't wait for
|
|
501
|
-
fetch(
|
|
507
|
+
// Start the prompt (don't wait for the body, events come via Bus)
|
|
508
|
+
const requestPromise = fetch(
|
|
502
509
|
`http://${server.hostname}:${server.port}/session/${sessionID}/message`,
|
|
503
510
|
{
|
|
504
511
|
method: 'POST',
|
|
@@ -526,8 +533,10 @@ async function runServerMode(
|
|
|
526
533
|
});
|
|
527
534
|
});
|
|
528
535
|
|
|
529
|
-
// Wait for session to become idle
|
|
536
|
+
// Wait for session to become idle, then for the request rejection to be
|
|
537
|
+
// observed (see runDirectMode, #290).
|
|
530
538
|
await eventPromise;
|
|
539
|
+
await requestPromise;
|
|
531
540
|
} finally {
|
|
532
541
|
// Always clean up resources
|
|
533
542
|
if (unsub) {
|
|
@@ -590,7 +599,7 @@ async function runDirectMode(
|
|
|
590
599
|
const parts = [{ type: 'text', text: message }];
|
|
591
600
|
|
|
592
601
|
// Start the prompt directly without HTTP
|
|
593
|
-
SessionPrompt.prompt({
|
|
602
|
+
const promptPromise = SessionPrompt.prompt({
|
|
594
603
|
sessionID,
|
|
595
604
|
parts,
|
|
596
605
|
model: {
|
|
@@ -612,8 +621,12 @@ async function runDirectMode(
|
|
|
612
621
|
});
|
|
613
622
|
});
|
|
614
623
|
|
|
615
|
-
// Wait for session to become idle
|
|
624
|
+
// Wait for session to become idle, then for the prompt itself. A fatal
|
|
625
|
+
// failure publishes `session.idle` while unwinding, so awaiting only the
|
|
626
|
+
// idle event let the process exit before the rejection handler above ran
|
|
627
|
+
// — no error record, exit code 0 (#290).
|
|
616
628
|
await eventPromise;
|
|
629
|
+
await promptPromise;
|
|
617
630
|
} finally {
|
|
618
631
|
// Always clean up resources
|
|
619
632
|
if (unsub) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Log } from '../util/log';
|
|
2
2
|
import { config } from '../config/config';
|
|
3
|
+
import { isUnreachableNetworkError } from '../util/network-error';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Custom fetch wrapper that handles rate limits (HTTP 429) and server errors (HTTP 5xx)
|
|
@@ -295,12 +296,17 @@ export namespace RetryFetch {
|
|
|
295
296
|
function isRetryableError(error: unknown): boolean {
|
|
296
297
|
if (!(error instanceof Error)) return false;
|
|
297
298
|
|
|
299
|
+
// Connection refused / host not found is not transient — nothing will
|
|
300
|
+
// start listening as a side effect of retrying, so do not burn the
|
|
301
|
+
// (default 7 day) retry budget on it.
|
|
302
|
+
// See: https://github.com/link-assistant/agent/issues/290
|
|
303
|
+
if (isUnreachableNetworkError(error)) return false;
|
|
304
|
+
|
|
298
305
|
// Socket/connection errors (Bun has known timeout issues)
|
|
299
306
|
// See: https://github.com/oven-sh/bun/issues/14439
|
|
300
307
|
if (
|
|
301
308
|
error.message.includes('ConnectionClosed') ||
|
|
302
309
|
error.message.includes('ECONNRESET') ||
|
|
303
|
-
error.message.includes('ECONNREFUSED') ||
|
|
304
310
|
error.message.includes('socket') ||
|
|
305
311
|
error.message.includes('connection')
|
|
306
312
|
) {
|
|
@@ -13,6 +13,7 @@ import { Identifier } from '../id/id';
|
|
|
13
13
|
import { Snapshot } from '../snapshot';
|
|
14
14
|
import { fn } from '../util/fn';
|
|
15
15
|
import { Storage } from '../storage/storage';
|
|
16
|
+
import { isUnreachableNetworkError } from '../util/network-error';
|
|
16
17
|
|
|
17
18
|
export namespace MessageV2 {
|
|
18
19
|
export const OutputLengthError = NamedError.create(
|
|
@@ -920,7 +921,10 @@ export namespace MessageV2 {
|
|
|
920
921
|
{
|
|
921
922
|
message: e.message,
|
|
922
923
|
statusCode: e.statusCode,
|
|
923
|
-
|
|
924
|
+
// A refused connection / unresolvable host is not transient: the
|
|
925
|
+
// AI SDK marks it retryable, but retrying it for the 7-day global
|
|
926
|
+
// budget makes the CLI hang forever (#290).
|
|
927
|
+
isRetryable: isUnreachableNetworkError(e) ? false : e.isRetryable,
|
|
924
928
|
responseHeaders: e.responseHeaders,
|
|
925
929
|
responseBody: e.responseBody,
|
|
926
930
|
},
|
package/src/session/prompt.ts
CHANGED
|
@@ -457,6 +457,21 @@ export namespace SessionPrompt {
|
|
|
457
457
|
stack: error instanceof Error ? error.stack : undefined,
|
|
458
458
|
hint: 'Check that the model exists in the provider. Use --verbose for more details.',
|
|
459
459
|
}));
|
|
460
|
+
// Publish the fatal error on the bus so streaming consumers see an
|
|
461
|
+
// `error` event instead of only a `log` record, and so the CLI can
|
|
462
|
+
// exit non-zero (#290, recurrence of #22).
|
|
463
|
+
const cause = error instanceof Error ? error : new Error(String(error));
|
|
464
|
+
const fatal = new Error(
|
|
465
|
+
`Failed to initialize model "${lastUser.model.providerID}/${lastUser.model.modelID}": ${cause.message || cause.name}`,
|
|
466
|
+
{ cause }
|
|
467
|
+
);
|
|
468
|
+
fatal.name = cause.name;
|
|
469
|
+
Bus.publish(Session.Event.Error, {
|
|
470
|
+
sessionID,
|
|
471
|
+
error: MessageV2.fromError(fatal, {
|
|
472
|
+
providerID: lastUser.model.providerID,
|
|
473
|
+
}),
|
|
474
|
+
});
|
|
460
475
|
// Re-throw the error so it can be handled by the caller
|
|
461
476
|
throw error;
|
|
462
477
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Classification of network-level errors that are *not* transient.
|
|
3
|
+
*
|
|
4
|
+
* A refused connection or an unresolvable host will not start working as a
|
|
5
|
+
* side effect of retrying: nothing is listening on the port, or the name does
|
|
6
|
+
* not resolve. Retrying those for the global retry budget (7 days by default,
|
|
7
|
+
* see #167) makes the CLI hang forever instead of failing fast.
|
|
8
|
+
*
|
|
9
|
+
* Transient DNS failures (EAI_AGAIN) are deliberately NOT included — those can
|
|
10
|
+
* recover on their own and stay retryable.
|
|
11
|
+
*
|
|
12
|
+
* @see https://github.com/link-assistant/agent/issues/290
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Error codes/markers that mean "the endpoint is unreachable, permanently". */
|
|
16
|
+
const UNREACHABLE_MARKERS = [
|
|
17
|
+
'ConnectionRefused',
|
|
18
|
+
'ECONNREFUSED',
|
|
19
|
+
'ENOTFOUND',
|
|
20
|
+
'EAI_NONAME',
|
|
21
|
+
'ERR_NAME_NOT_RESOLVED',
|
|
22
|
+
'failed to lookup address information',
|
|
23
|
+
'Unable to connect. Is the computer able to access the url?',
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
function collectStrings(value: unknown, depth: number, out: string[]): void {
|
|
27
|
+
if (depth > 5 || value === null || value === undefined) return;
|
|
28
|
+
|
|
29
|
+
if (typeof value === 'string') {
|
|
30
|
+
out.push(value);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (typeof value !== 'object') return;
|
|
35
|
+
|
|
36
|
+
const record = value as Record<string, unknown>;
|
|
37
|
+
for (const key of ['code', 'errno', 'name', 'message', 'syscall']) {
|
|
38
|
+
const field = record[key];
|
|
39
|
+
if (typeof field === 'string') out.push(field);
|
|
40
|
+
}
|
|
41
|
+
collectStrings(record['cause'], depth + 1, out);
|
|
42
|
+
collectStrings(record['error'], depth + 1, out);
|
|
43
|
+
collectStrings(record['data'], depth + 1, out);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Returns true when the error means the endpoint cannot be reached at all
|
|
48
|
+
* (connection refused, host not found) and retrying cannot help.
|
|
49
|
+
*/
|
|
50
|
+
export function isUnreachableNetworkError(error: unknown): boolean {
|
|
51
|
+
const haystack: string[] = [];
|
|
52
|
+
collectStrings(error, 0, haystack);
|
|
53
|
+
const joined = haystack.join('\n');
|
|
54
|
+
if (!joined) return false;
|
|
55
|
+
return UNREACHABLE_MARKERS.some((marker) => joined.includes(marker));
|
|
56
|
+
}
|