@link-assistant/agent 0.25.5 → 0.25.7
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/cmd/run.ts +5 -1
- package/src/cli/continuous-mode.js +13 -2
- package/src/cli/event-handler.js +5 -1
- package/src/index.js +21 -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/error-text.ts +82 -0
- package/src/util/network-error.ts +56 -0
package/package.json
CHANGED
package/src/cli/cmd/run.ts
CHANGED
|
@@ -264,7 +264,11 @@ export const RunCommand = cmd({
|
|
|
264
264
|
err = String(props.error.data.message);
|
|
265
265
|
}
|
|
266
266
|
errorMsg = errorMsg ? errorMsg + EOL + err : err;
|
|
267
|
-
|
|
267
|
+
// Always include the human-readable string alongside the
|
|
268
|
+
// machine-readable object so consumers never print
|
|
269
|
+
// "[object Object]" (#289).
|
|
270
|
+
if (outputJsonEvent('error', { message: err, error: props.error }))
|
|
271
|
+
continue;
|
|
268
272
|
UI.error(err);
|
|
269
273
|
}
|
|
270
274
|
|
|
@@ -11,6 +11,7 @@ import { SessionPrompt } from '../session/prompt.ts';
|
|
|
11
11
|
import { createEventHandler, serializeOutput } from '../json-standard/index.ts';
|
|
12
12
|
import { createContinuousStdinReader } from './input-queue.js';
|
|
13
13
|
import { outputBusEvent } from './event-handler.js';
|
|
14
|
+
import { errorText } from '../util/error-text.ts';
|
|
14
15
|
import { Permission } from '../permission/index.ts';
|
|
15
16
|
import { Log } from '../util/log.ts';
|
|
16
17
|
import { config } from '../config/config.ts';
|
|
@@ -365,7 +366,7 @@ export async function runContinuousServerMode(
|
|
|
365
366
|
});
|
|
366
367
|
|
|
367
368
|
// Send message
|
|
368
|
-
fetch(
|
|
369
|
+
const requestPromise = fetch(
|
|
369
370
|
`http://${server.hostname}:${server.port}/session/${sessionID}/message`,
|
|
370
371
|
{
|
|
371
372
|
method: 'POST',
|
|
@@ -385,11 +386,15 @@ export async function runContinuousServerMode(
|
|
|
385
386
|
type: 'error',
|
|
386
387
|
timestamp: Date.now(),
|
|
387
388
|
sessionID,
|
|
389
|
+
message: errorText(error),
|
|
388
390
|
error: error instanceof Error ? error.message : String(error),
|
|
389
391
|
});
|
|
390
392
|
});
|
|
391
393
|
|
|
394
|
+
// See the direct-mode comment below: the request rejection must be
|
|
395
|
+
// observed before the process can exit (#290).
|
|
392
396
|
await messagePromise;
|
|
397
|
+
await requestPromise;
|
|
393
398
|
isProcessing = false;
|
|
394
399
|
|
|
395
400
|
// Process next pending message if any
|
|
@@ -584,7 +589,7 @@ export async function runContinuousDirectMode(
|
|
|
584
589
|
});
|
|
585
590
|
|
|
586
591
|
// Send message directly
|
|
587
|
-
SessionPrompt.prompt({
|
|
592
|
+
const promptPromise = SessionPrompt.prompt({
|
|
588
593
|
sessionID,
|
|
589
594
|
parts,
|
|
590
595
|
model: { providerID, modelID },
|
|
@@ -598,11 +603,17 @@ export async function runContinuousDirectMode(
|
|
|
598
603
|
type: 'error',
|
|
599
604
|
timestamp: Date.now(),
|
|
600
605
|
sessionID,
|
|
606
|
+
message: errorText(error),
|
|
601
607
|
error: error instanceof Error ? error.message : String(error),
|
|
602
608
|
});
|
|
603
609
|
});
|
|
604
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).
|
|
605
615
|
await messagePromise;
|
|
616
|
+
await promptPromise;
|
|
606
617
|
isProcessing = false;
|
|
607
618
|
|
|
608
619
|
// Process next pending message if any
|
package/src/cli/event-handler.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { Bus } from '../bus/index.ts';
|
|
7
|
+
import { errorText } from '../util/error-text.ts';
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Create a subscription for session bus events that outputs events in the selected JSON format.
|
|
@@ -90,11 +91,13 @@ export function outputBusEvent({
|
|
|
90
91
|
|
|
91
92
|
// If tool failed, also output an error event
|
|
92
93
|
if (part.state?.status === 'error') {
|
|
94
|
+
const stateError = part.state.error;
|
|
93
95
|
eventHandler.output({
|
|
94
96
|
type: 'error',
|
|
95
97
|
timestamp: Date.now(),
|
|
96
98
|
sessionID,
|
|
97
|
-
|
|
99
|
+
message: errorText(stateError, 'Tool execution failed'),
|
|
100
|
+
error: stateError || 'Tool execution failed',
|
|
98
101
|
});
|
|
99
102
|
}
|
|
100
103
|
}
|
|
@@ -145,6 +148,7 @@ export function outputBusEvent({
|
|
|
145
148
|
type: 'error',
|
|
146
149
|
timestamp: Date.now(),
|
|
147
150
|
sessionID,
|
|
151
|
+
message: errorText(props.error),
|
|
148
152
|
error: props.error,
|
|
149
153
|
});
|
|
150
154
|
}
|
package/src/index.js
CHANGED
|
@@ -27,6 +27,7 @@ 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 { errorText } from './util/error-text.ts';
|
|
30
31
|
import {
|
|
31
32
|
createVerboseFetch,
|
|
32
33
|
registerPendingStreamLogExitHandler,
|
|
@@ -35,6 +36,7 @@ import {
|
|
|
35
36
|
runContinuousServerMode,
|
|
36
37
|
runContinuousDirectMode,
|
|
37
38
|
resolveResumeSession,
|
|
39
|
+
getHasError,
|
|
38
40
|
} from './cli/continuous-mode.js';
|
|
39
41
|
import { parseStreamJsonInput } from './cli/input-queue.js';
|
|
40
42
|
import { createBusEventSubscription } from './cli/event-handler.js';
|
|
@@ -424,6 +426,12 @@ async function runContinuousAgentMode(argv) {
|
|
|
424
426
|
},
|
|
425
427
|
});
|
|
426
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
|
+
|
|
427
435
|
// Explicitly exit to ensure process terminates (#213)
|
|
428
436
|
Log.Default.info(() => ({
|
|
429
437
|
message: 'Agent exiting',
|
|
@@ -496,8 +504,8 @@ async function runServerMode(
|
|
|
496
504
|
const message = request.message || 'hi';
|
|
497
505
|
const parts = [{ type: 'text', text: message }];
|
|
498
506
|
|
|
499
|
-
// Start the prompt (don't wait for
|
|
500
|
-
fetch(
|
|
507
|
+
// Start the prompt (don't wait for the body, events come via Bus)
|
|
508
|
+
const requestPromise = fetch(
|
|
501
509
|
`http://${server.hostname}:${server.port}/session/${sessionID}/message`,
|
|
502
510
|
{
|
|
503
511
|
method: 'POST',
|
|
@@ -520,12 +528,15 @@ async function runServerMode(
|
|
|
520
528
|
type: 'error',
|
|
521
529
|
timestamp: Date.now(),
|
|
522
530
|
sessionID,
|
|
531
|
+
message: errorText(error),
|
|
523
532
|
error: error instanceof Error ? error.message : String(error),
|
|
524
533
|
});
|
|
525
534
|
});
|
|
526
535
|
|
|
527
|
-
// 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).
|
|
528
538
|
await eventPromise;
|
|
539
|
+
await requestPromise;
|
|
529
540
|
} finally {
|
|
530
541
|
// Always clean up resources
|
|
531
542
|
if (unsub) {
|
|
@@ -588,7 +599,7 @@ async function runDirectMode(
|
|
|
588
599
|
const parts = [{ type: 'text', text: message }];
|
|
589
600
|
|
|
590
601
|
// Start the prompt directly without HTTP
|
|
591
|
-
SessionPrompt.prompt({
|
|
602
|
+
const promptPromise = SessionPrompt.prompt({
|
|
592
603
|
sessionID,
|
|
593
604
|
parts,
|
|
594
605
|
model: {
|
|
@@ -605,12 +616,17 @@ async function runDirectMode(
|
|
|
605
616
|
type: 'error',
|
|
606
617
|
timestamp: Date.now(),
|
|
607
618
|
sessionID,
|
|
619
|
+
message: errorText(error),
|
|
608
620
|
error: error instanceof Error ? error.message : String(error),
|
|
609
621
|
});
|
|
610
622
|
});
|
|
611
623
|
|
|
612
|
-
// 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).
|
|
613
628
|
await eventPromise;
|
|
629
|
+
await promptPromise;
|
|
614
630
|
} finally {
|
|
615
631
|
// Always clean up resources
|
|
616
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,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Human-readable rendering of arbitrary error values (issue #289).
|
|
3
|
+
*
|
|
4
|
+
* JSON `error` events used to carry only the machine-readable
|
|
5
|
+
* `NamedError.toObject()` shape (`{name, data: {message}}`), so consumers doing
|
|
6
|
+
* `` `${record.error}` `` published `[object Object]` and the real cause was
|
|
7
|
+
* lost. Every emitted `error` event now also carries a `message` string
|
|
8
|
+
* produced by `stringifyErrorValue`.
|
|
9
|
+
*
|
|
10
|
+
* The renderer is circular-safe and depth-limited, and never invents a
|
|
11
|
+
* placeholder: when nothing readable can be derived it returns an empty
|
|
12
|
+
* string so the caller can pick its own fallback.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const MAX_DEPTH = 5;
|
|
16
|
+
|
|
17
|
+
function fromParts(name: string | undefined, message: string | undefined) {
|
|
18
|
+
if (name && message) return `${name}: ${message}`;
|
|
19
|
+
return message || name || '';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function render(value: unknown, depth: number, seen: Set<object>): string {
|
|
23
|
+
if (value === null || value === undefined) return '';
|
|
24
|
+
if (typeof value === 'string') return value.trim();
|
|
25
|
+
if (typeof value === 'number' || typeof value === 'boolean')
|
|
26
|
+
return String(value);
|
|
27
|
+
if (typeof value !== 'object') return '';
|
|
28
|
+
|
|
29
|
+
const object = value as Record<string, unknown>;
|
|
30
|
+
if (seen.has(object)) return '';
|
|
31
|
+
if (depth >= MAX_DEPTH) return '';
|
|
32
|
+
seen.add(object);
|
|
33
|
+
|
|
34
|
+
if (Array.isArray(object)) {
|
|
35
|
+
const rendered = object
|
|
36
|
+
.map((item) => render(item, depth + 1, seen))
|
|
37
|
+
.filter((item) => item.length > 0);
|
|
38
|
+
return rendered.join('; ');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (object instanceof Error) {
|
|
42
|
+
return fromParts(object.name, object.message) || String(object);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// NamedError.toObject(): { name, data: { message, ... } }
|
|
46
|
+
const name = typeof object.name === 'string' ? object.name : undefined;
|
|
47
|
+
const data = object.data;
|
|
48
|
+
if (data && typeof data === 'object') {
|
|
49
|
+
const dataMessage = render(
|
|
50
|
+
(data as Record<string, unknown>).message,
|
|
51
|
+
depth + 1,
|
|
52
|
+
seen
|
|
53
|
+
);
|
|
54
|
+
if (dataMessage) return fromParts(name, dataMessage);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const message = render(object.message, depth + 1, seen);
|
|
58
|
+
if (message) return fromParts(name, message);
|
|
59
|
+
|
|
60
|
+
// Envelope shapes: { error: … }, { cause: … }
|
|
61
|
+
const nested =
|
|
62
|
+
render(object.error, depth + 1, seen) ||
|
|
63
|
+
render(object.cause, depth + 1, seen);
|
|
64
|
+
if (nested) return name ? fromParts(name, nested) : nested;
|
|
65
|
+
|
|
66
|
+
return name || '';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Render any error-ish value as a human-readable string.
|
|
71
|
+
* Returns an empty string when nothing readable can be derived.
|
|
72
|
+
*/
|
|
73
|
+
export function stringifyErrorValue(value: unknown): string {
|
|
74
|
+
return render(value, 0, new Set<object>());
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Same as {@link stringifyErrorValue} but guarantees a non-empty string.
|
|
79
|
+
*/
|
|
80
|
+
export function errorText(value: unknown, fallback = 'Unknown error'): string {
|
|
81
|
+
return stringifyErrorValue(value) || fallback;
|
|
82
|
+
}
|
|
@@ -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
|
+
}
|