@link-assistant/agent 0.7.0 → 0.8.1
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 +9 -9
- package/src/cli/continuous-mode.js +188 -18
- package/src/cli/event-handler.js +99 -0
- package/src/config/config.ts +51 -0
- package/src/index.js +82 -157
- package/src/mcp/index.ts +125 -0
- package/src/session/message-v2.ts +30 -1
- package/src/session/processor.ts +21 -2
- package/src/session/prompt.ts +23 -1
- package/src/session/retry.ts +18 -0
- package/EXAMPLES.md +0 -462
- package/LICENSE +0 -24
- package/MODELS.md +0 -143
- package/README.md +0 -616
- package/TOOLS.md +0 -154
package/src/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { Server } from './server/server.ts';
|
|
4
4
|
import { Instance } from './project/instance.ts';
|
|
5
5
|
import { Log } from './util/log.ts';
|
|
6
|
-
|
|
6
|
+
// Bus is used via createBusEventSubscription in event-handler.js
|
|
7
7
|
import { Session } from './session/index.ts';
|
|
8
8
|
import { SessionPrompt } from './session/prompt.ts';
|
|
9
9
|
// EOL is reserved for future use
|
|
@@ -21,7 +21,9 @@ import { UI } from './cli/ui.ts';
|
|
|
21
21
|
import {
|
|
22
22
|
runContinuousServerMode,
|
|
23
23
|
runContinuousDirectMode,
|
|
24
|
+
resolveResumeSession,
|
|
24
25
|
} from './cli/continuous-mode.js';
|
|
26
|
+
import { createBusEventSubscription } from './cli/event-handler.js';
|
|
25
27
|
import { createRequire } from 'module';
|
|
26
28
|
import { readFileSync } from 'fs';
|
|
27
29
|
import { dirname, join } from 'path';
|
|
@@ -285,6 +287,7 @@ async function runAgentMode(argv, request) {
|
|
|
285
287
|
if (argv.server) {
|
|
286
288
|
// SERVER MODE: Start server and communicate via HTTP
|
|
287
289
|
await runServerMode(
|
|
290
|
+
argv,
|
|
288
291
|
request,
|
|
289
292
|
providerID,
|
|
290
293
|
modelID,
|
|
@@ -295,6 +298,7 @@ async function runAgentMode(argv, request) {
|
|
|
295
298
|
} else {
|
|
296
299
|
// DIRECT MODE: Run everything in single process
|
|
297
300
|
await runDirectMode(
|
|
301
|
+
argv,
|
|
298
302
|
request,
|
|
299
303
|
providerID,
|
|
300
304
|
modelID,
|
|
@@ -382,6 +386,7 @@ async function runContinuousAgentMode(argv) {
|
|
|
382
386
|
}
|
|
383
387
|
|
|
384
388
|
async function runServerMode(
|
|
389
|
+
argv,
|
|
385
390
|
request,
|
|
386
391
|
providerID,
|
|
387
392
|
modelID,
|
|
@@ -389,102 +394,52 @@ async function runServerMode(
|
|
|
389
394
|
appendSystemMessage,
|
|
390
395
|
jsonStandard
|
|
391
396
|
) {
|
|
397
|
+
const compactJson = argv['compact-json'] === true;
|
|
398
|
+
|
|
392
399
|
// Start server like OpenCode does
|
|
393
400
|
const server = Server.listen({ port: 0, hostname: '127.0.0.1' });
|
|
394
401
|
let unsub = null;
|
|
395
402
|
|
|
396
403
|
try {
|
|
397
|
-
//
|
|
398
|
-
const
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
404
|
+
// Check if we should resume an existing session
|
|
405
|
+
const resumeInfo = await resolveResumeSession(argv, compactJson);
|
|
406
|
+
|
|
407
|
+
let sessionID;
|
|
408
|
+
|
|
409
|
+
if (resumeInfo) {
|
|
410
|
+
// Use the resumed/forked session
|
|
411
|
+
sessionID = resumeInfo.sessionID;
|
|
412
|
+
} else {
|
|
413
|
+
// Create a new session
|
|
414
|
+
const createRes = await fetch(
|
|
415
|
+
`http://${server.hostname}:${server.port}/session`,
|
|
416
|
+
{
|
|
417
|
+
method: 'POST',
|
|
418
|
+
headers: { 'Content-Type': 'application/json' },
|
|
419
|
+
body: JSON.stringify({}),
|
|
420
|
+
}
|
|
421
|
+
);
|
|
422
|
+
const session = await createRes.json();
|
|
423
|
+
sessionID = session.id;
|
|
408
424
|
|
|
409
|
-
|
|
410
|
-
|
|
425
|
+
if (!sessionID) {
|
|
426
|
+
throw new Error('Failed to create session');
|
|
427
|
+
}
|
|
411
428
|
}
|
|
412
429
|
|
|
413
430
|
// Create event handler for the selected JSON standard
|
|
414
431
|
const eventHandler = createEventHandler(jsonStandard, sessionID);
|
|
415
432
|
|
|
416
433
|
// Subscribe to all bus events and output in selected format
|
|
417
|
-
const
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
if (part.sessionID !== sessionID) {
|
|
423
|
-
return;
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
// Output different event types
|
|
427
|
-
if (part.type === 'step-start') {
|
|
428
|
-
eventHandler.output({
|
|
429
|
-
type: 'step_start',
|
|
430
|
-
timestamp: Date.now(),
|
|
431
|
-
sessionID,
|
|
432
|
-
part,
|
|
433
|
-
});
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
if (part.type === 'step-finish') {
|
|
437
|
-
eventHandler.output({
|
|
438
|
-
type: 'step_finish',
|
|
439
|
-
timestamp: Date.now(),
|
|
440
|
-
sessionID,
|
|
441
|
-
part,
|
|
442
|
-
});
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
if (part.type === 'text' && part.time?.end) {
|
|
446
|
-
eventHandler.output({
|
|
447
|
-
type: 'text',
|
|
448
|
-
timestamp: Date.now(),
|
|
449
|
-
sessionID,
|
|
450
|
-
part,
|
|
451
|
-
});
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
if (part.type === 'tool' && part.state.status === 'completed') {
|
|
455
|
-
eventHandler.output({
|
|
456
|
-
type: 'tool_use',
|
|
457
|
-
timestamp: Date.now(),
|
|
458
|
-
sessionID,
|
|
459
|
-
part,
|
|
460
|
-
});
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
// Handle session idle to know when to stop
|
|
465
|
-
if (
|
|
466
|
-
event.type === 'session.idle' &&
|
|
467
|
-
event.properties.sessionID === sessionID
|
|
468
|
-
) {
|
|
469
|
-
resolve();
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
// Handle errors
|
|
473
|
-
if (event.type === 'session.error') {
|
|
474
|
-
const props = event.properties;
|
|
475
|
-
if (props.sessionID !== sessionID || !props.error) {
|
|
476
|
-
return;
|
|
477
|
-
}
|
|
434
|
+
const { unsub: eventUnsub, idlePromise: eventPromise } =
|
|
435
|
+
createBusEventSubscription({
|
|
436
|
+
sessionID,
|
|
437
|
+
eventHandler,
|
|
438
|
+
onError: () => {
|
|
478
439
|
hasError = true;
|
|
479
|
-
|
|
480
|
-
type: 'error',
|
|
481
|
-
timestamp: Date.now(),
|
|
482
|
-
sessionID,
|
|
483
|
-
error: props.error,
|
|
484
|
-
});
|
|
485
|
-
}
|
|
440
|
+
},
|
|
486
441
|
});
|
|
487
|
-
|
|
442
|
+
unsub = eventUnsub;
|
|
488
443
|
|
|
489
444
|
// Send message to session with specified model (default: opencode/grok-code)
|
|
490
445
|
const message = request.message || 'hi';
|
|
@@ -529,6 +484,7 @@ async function runServerMode(
|
|
|
529
484
|
}
|
|
530
485
|
|
|
531
486
|
async function runDirectMode(
|
|
487
|
+
argv,
|
|
532
488
|
request,
|
|
533
489
|
providerID,
|
|
534
490
|
modelID,
|
|
@@ -536,91 +492,41 @@ async function runDirectMode(
|
|
|
536
492
|
appendSystemMessage,
|
|
537
493
|
jsonStandard
|
|
538
494
|
) {
|
|
495
|
+
const compactJson = argv['compact-json'] === true;
|
|
496
|
+
|
|
539
497
|
// DIRECT MODE: Run in single process without server
|
|
540
498
|
let unsub = null;
|
|
541
499
|
|
|
542
500
|
try {
|
|
543
|
-
//
|
|
544
|
-
const
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
501
|
+
// Check if we should resume an existing session
|
|
502
|
+
const resumeInfo = await resolveResumeSession(argv, compactJson);
|
|
503
|
+
|
|
504
|
+
let sessionID;
|
|
505
|
+
|
|
506
|
+
if (resumeInfo) {
|
|
507
|
+
// Use the resumed/forked session
|
|
508
|
+
sessionID = resumeInfo.sessionID;
|
|
509
|
+
} else {
|
|
510
|
+
// Create a new session directly
|
|
511
|
+
const session = await Session.createNext({
|
|
512
|
+
directory: process.cwd(),
|
|
513
|
+
});
|
|
514
|
+
sessionID = session.id;
|
|
515
|
+
}
|
|
548
516
|
|
|
549
517
|
// Create event handler for the selected JSON standard
|
|
550
518
|
const eventHandler = createEventHandler(jsonStandard, sessionID);
|
|
551
519
|
|
|
552
520
|
// Subscribe to all bus events and output in selected format
|
|
553
|
-
const
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
if (part.sessionID !== sessionID) {
|
|
559
|
-
return;
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
// Output different event types
|
|
563
|
-
if (part.type === 'step-start') {
|
|
564
|
-
eventHandler.output({
|
|
565
|
-
type: 'step_start',
|
|
566
|
-
timestamp: Date.now(),
|
|
567
|
-
sessionID,
|
|
568
|
-
part,
|
|
569
|
-
});
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
if (part.type === 'step-finish') {
|
|
573
|
-
eventHandler.output({
|
|
574
|
-
type: 'step_finish',
|
|
575
|
-
timestamp: Date.now(),
|
|
576
|
-
sessionID,
|
|
577
|
-
part,
|
|
578
|
-
});
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
if (part.type === 'text' && part.time?.end) {
|
|
582
|
-
eventHandler.output({
|
|
583
|
-
type: 'text',
|
|
584
|
-
timestamp: Date.now(),
|
|
585
|
-
sessionID,
|
|
586
|
-
part,
|
|
587
|
-
});
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
if (part.type === 'tool' && part.state.status === 'completed') {
|
|
591
|
-
eventHandler.output({
|
|
592
|
-
type: 'tool_use',
|
|
593
|
-
timestamp: Date.now(),
|
|
594
|
-
sessionID,
|
|
595
|
-
part,
|
|
596
|
-
});
|
|
597
|
-
}
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
// Handle session idle to know when to stop
|
|
601
|
-
if (
|
|
602
|
-
event.type === 'session.idle' &&
|
|
603
|
-
event.properties.sessionID === sessionID
|
|
604
|
-
) {
|
|
605
|
-
resolve();
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
// Handle errors
|
|
609
|
-
if (event.type === 'session.error') {
|
|
610
|
-
const props = event.properties;
|
|
611
|
-
if (props.sessionID !== sessionID || !props.error) {
|
|
612
|
-
return;
|
|
613
|
-
}
|
|
521
|
+
const { unsub: eventUnsub, idlePromise: eventPromise } =
|
|
522
|
+
createBusEventSubscription({
|
|
523
|
+
sessionID,
|
|
524
|
+
eventHandler,
|
|
525
|
+
onError: () => {
|
|
614
526
|
hasError = true;
|
|
615
|
-
|
|
616
|
-
type: 'error',
|
|
617
|
-
timestamp: Date.now(),
|
|
618
|
-
sessionID,
|
|
619
|
-
error: props.error,
|
|
620
|
-
});
|
|
621
|
-
}
|
|
527
|
+
},
|
|
622
528
|
});
|
|
623
|
-
|
|
529
|
+
unsub = eventUnsub;
|
|
624
530
|
|
|
625
531
|
// Send message to session directly
|
|
626
532
|
const message = request.message || 'hi';
|
|
@@ -765,6 +671,25 @@ async function main() {
|
|
|
765
671
|
description:
|
|
766
672
|
'Output compact JSON (single line) instead of pretty-printed JSON (default: false). Useful for program-to-program communication.',
|
|
767
673
|
default: false,
|
|
674
|
+
})
|
|
675
|
+
.option('resume', {
|
|
676
|
+
alias: 'r',
|
|
677
|
+
type: 'string',
|
|
678
|
+
description:
|
|
679
|
+
'Resume a specific session by ID. By default, forks the session with a new UUID. Use --no-fork to continue in the same session.',
|
|
680
|
+
})
|
|
681
|
+
.option('continue', {
|
|
682
|
+
alias: 'c',
|
|
683
|
+
type: 'boolean',
|
|
684
|
+
description:
|
|
685
|
+
'Continue the most recent session. By default, forks the session with a new UUID. Use --no-fork to continue in the same session.',
|
|
686
|
+
default: false,
|
|
687
|
+
})
|
|
688
|
+
.option('no-fork', {
|
|
689
|
+
type: 'boolean',
|
|
690
|
+
description:
|
|
691
|
+
'When used with --resume or --continue, continue in the same session without forking to a new UUID.',
|
|
692
|
+
default: false,
|
|
768
693
|
}),
|
|
769
694
|
handler: async (argv) => {
|
|
770
695
|
const compactJson = argv['compact-json'] === true;
|
package/src/mcp/index.ts
CHANGED
|
@@ -13,6 +13,18 @@ import { withTimeout } from '../util/timeout';
|
|
|
13
13
|
export namespace MCP {
|
|
14
14
|
const log = Log.create({ service: 'mcp' });
|
|
15
15
|
|
|
16
|
+
/** Built-in default timeout for MCP tool execution (2 minutes) */
|
|
17
|
+
export const BUILTIN_DEFAULT_TOOL_CALL_TIMEOUT = 120000;
|
|
18
|
+
|
|
19
|
+
/** Built-in maximum timeout for MCP tool execution (10 minutes) */
|
|
20
|
+
export const BUILTIN_MAX_TOOL_CALL_TIMEOUT = 600000;
|
|
21
|
+
|
|
22
|
+
/** @deprecated Use BUILTIN_DEFAULT_TOOL_CALL_TIMEOUT instead */
|
|
23
|
+
export const DEFAULT_TOOL_CALL_TIMEOUT = BUILTIN_DEFAULT_TOOL_CALL_TIMEOUT;
|
|
24
|
+
|
|
25
|
+
/** @deprecated Use BUILTIN_MAX_TOOL_CALL_TIMEOUT instead */
|
|
26
|
+
export const MAX_TOOL_CALL_TIMEOUT = BUILTIN_MAX_TOOL_CALL_TIMEOUT;
|
|
27
|
+
|
|
16
28
|
export const Failed = NamedError.create(
|
|
17
29
|
'MCPFailed',
|
|
18
30
|
z.object({
|
|
@@ -22,6 +34,22 @@ export namespace MCP {
|
|
|
22
34
|
|
|
23
35
|
type Client = Awaited<ReturnType<typeof experimental_createMCPClient>>;
|
|
24
36
|
|
|
37
|
+
/** Global timeout defaults from configuration */
|
|
38
|
+
export interface GlobalTimeoutDefaults {
|
|
39
|
+
/** Global default timeout for MCP tool calls */
|
|
40
|
+
defaultTimeout: number;
|
|
41
|
+
/** Global maximum timeout for MCP tool calls */
|
|
42
|
+
maxTimeout: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Timeout configuration for an MCP server */
|
|
46
|
+
export interface TimeoutConfig {
|
|
47
|
+
/** Default timeout for all tool calls from this server */
|
|
48
|
+
defaultTimeout: number;
|
|
49
|
+
/** Per-tool timeout overrides */
|
|
50
|
+
toolTimeouts: Record<string, number>;
|
|
51
|
+
}
|
|
52
|
+
|
|
25
53
|
export const Status = z
|
|
26
54
|
.discriminatedUnion('status', [
|
|
27
55
|
z
|
|
@@ -59,6 +87,26 @@ export namespace MCP {
|
|
|
59
87
|
const config = cfg.mcp ?? {};
|
|
60
88
|
const clients: Record<string, Client> = {};
|
|
61
89
|
const status: Record<string, Status> = {};
|
|
90
|
+
const timeoutConfigs: Record<string, TimeoutConfig> = {};
|
|
91
|
+
|
|
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
|
+
|
|
100
|
+
const globalDefaults: GlobalTimeoutDefaults = {
|
|
101
|
+
defaultTimeout:
|
|
102
|
+
cfg.mcp_defaults?.tool_call_timeout ??
|
|
103
|
+
envDefaultTimeout ??
|
|
104
|
+
BUILTIN_DEFAULT_TOOL_CALL_TIMEOUT,
|
|
105
|
+
maxTimeout:
|
|
106
|
+
cfg.mcp_defaults?.max_tool_call_timeout ??
|
|
107
|
+
envMaxTimeout ??
|
|
108
|
+
BUILTIN_MAX_TOOL_CALL_TIMEOUT,
|
|
109
|
+
};
|
|
62
110
|
|
|
63
111
|
await Promise.all(
|
|
64
112
|
Object.entries(config).map(async ([key, mcp]) => {
|
|
@@ -67,6 +115,17 @@ export namespace MCP {
|
|
|
67
115
|
|
|
68
116
|
status[key] = result.status;
|
|
69
117
|
|
|
118
|
+
// Store timeout configuration for this MCP server
|
|
119
|
+
// Per-server timeout overrides global default, but is capped at global max
|
|
120
|
+
const defaultTimeout = Math.min(
|
|
121
|
+
mcp.tool_call_timeout ?? globalDefaults.defaultTimeout,
|
|
122
|
+
globalDefaults.maxTimeout
|
|
123
|
+
);
|
|
124
|
+
timeoutConfigs[key] = {
|
|
125
|
+
defaultTimeout,
|
|
126
|
+
toolTimeouts: mcp.tool_timeouts ?? {},
|
|
127
|
+
};
|
|
128
|
+
|
|
70
129
|
if (result.mcpClient) {
|
|
71
130
|
clients[key] = result.mcpClient;
|
|
72
131
|
}
|
|
@@ -75,6 +134,8 @@ export namespace MCP {
|
|
|
75
134
|
return {
|
|
76
135
|
status,
|
|
77
136
|
clients,
|
|
137
|
+
timeoutConfigs,
|
|
138
|
+
globalDefaults,
|
|
78
139
|
};
|
|
79
140
|
},
|
|
80
141
|
async (state) => {
|
|
@@ -310,4 +371,68 @@ export namespace MCP {
|
|
|
310
371
|
}
|
|
311
372
|
return result;
|
|
312
373
|
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Get the timeout configuration for all MCP servers
|
|
377
|
+
*/
|
|
378
|
+
export async function getTimeoutConfigs(): Promise<
|
|
379
|
+
Record<string, TimeoutConfig>
|
|
380
|
+
> {
|
|
381
|
+
const s = await state();
|
|
382
|
+
return s.timeoutConfigs;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Get the global timeout defaults from configuration
|
|
387
|
+
*/
|
|
388
|
+
export async function getGlobalDefaults(): Promise<GlobalTimeoutDefaults> {
|
|
389
|
+
const s = await state();
|
|
390
|
+
return s.globalDefaults;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Get the timeout for a specific MCP tool
|
|
395
|
+
* @param fullToolName The full tool name in format "serverName_toolName"
|
|
396
|
+
* @returns Timeout in milliseconds
|
|
397
|
+
*/
|
|
398
|
+
export async function getToolTimeout(fullToolName: string): Promise<number> {
|
|
399
|
+
const s = await state();
|
|
400
|
+
const maxTimeout = s.globalDefaults.maxTimeout;
|
|
401
|
+
|
|
402
|
+
// Parse the full tool name to extract server name and tool name
|
|
403
|
+
// Format: serverName_toolName (where both are sanitized)
|
|
404
|
+
const underscoreIndex = fullToolName.indexOf('_');
|
|
405
|
+
if (underscoreIndex === -1) {
|
|
406
|
+
return s.globalDefaults.defaultTimeout;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const serverName = fullToolName.substring(0, underscoreIndex);
|
|
410
|
+
const toolName = fullToolName.substring(underscoreIndex + 1);
|
|
411
|
+
|
|
412
|
+
// Find the server config (need to handle sanitization)
|
|
413
|
+
const config = s.timeoutConfigs[serverName];
|
|
414
|
+
if (!config) {
|
|
415
|
+
// Try to find by iterating (in case of sanitization differences)
|
|
416
|
+
for (const [key, cfg] of Object.entries(s.timeoutConfigs)) {
|
|
417
|
+
const sanitizedKey = key.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
418
|
+
if (sanitizedKey === serverName) {
|
|
419
|
+
// Check for per-tool timeout override
|
|
420
|
+
const perToolTimeout = cfg.toolTimeouts[toolName];
|
|
421
|
+
if (perToolTimeout !== undefined) {
|
|
422
|
+
return Math.min(perToolTimeout, maxTimeout);
|
|
423
|
+
}
|
|
424
|
+
return cfg.defaultTimeout;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return s.globalDefaults.defaultTimeout;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Check for per-tool timeout override
|
|
431
|
+
const perToolTimeout = config.toolTimeouts[toolName];
|
|
432
|
+
if (perToolTimeout !== undefined) {
|
|
433
|
+
return Math.min(perToolTimeout, maxTimeout);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
return config.defaultTimeout;
|
|
437
|
+
}
|
|
313
438
|
}
|
|
@@ -42,6 +42,22 @@ export namespace MessageV2 {
|
|
|
42
42
|
);
|
|
43
43
|
export type APIError = z.infer<typeof APIError.Schema>;
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Socket connection error - typically caused by Bun's 10-second idle timeout
|
|
47
|
+
* in Bun.serve() context. These errors are transient and should be retried.
|
|
48
|
+
* See: https://github.com/oven-sh/bun/issues/14439
|
|
49
|
+
*/
|
|
50
|
+
export const SocketConnectionError = NamedError.create(
|
|
51
|
+
'SocketConnectionError',
|
|
52
|
+
z.object({
|
|
53
|
+
message: z.string(),
|
|
54
|
+
isRetryable: z.literal(true),
|
|
55
|
+
})
|
|
56
|
+
);
|
|
57
|
+
export type SocketConnectionError = z.infer<
|
|
58
|
+
typeof SocketConnectionError.Schema
|
|
59
|
+
>;
|
|
60
|
+
|
|
45
61
|
const PartBase = z.object({
|
|
46
62
|
id: z.string(),
|
|
47
63
|
sessionID: z.string(),
|
|
@@ -787,11 +803,24 @@ export namespace MessageV2 {
|
|
|
787
803
|
},
|
|
788
804
|
{ cause: e }
|
|
789
805
|
).toObject();
|
|
790
|
-
case e instanceof Error:
|
|
806
|
+
case e instanceof Error: {
|
|
807
|
+
const message = e.message || e.toString();
|
|
808
|
+
// Detect Bun socket connection errors (known Bun issue with 10s idle timeout)
|
|
809
|
+
// See: https://github.com/oven-sh/bun/issues/14439
|
|
810
|
+
const isSocketError =
|
|
811
|
+
message.includes('socket connection was closed') ||
|
|
812
|
+
message.includes('closed unexpectedly');
|
|
813
|
+
if (isSocketError) {
|
|
814
|
+
return new MessageV2.SocketConnectionError(
|
|
815
|
+
{ message, isRetryable: true },
|
|
816
|
+
{ cause: e }
|
|
817
|
+
).toObject();
|
|
818
|
+
}
|
|
791
819
|
return new NamedError.Unknown(
|
|
792
820
|
{ message: e.toString() },
|
|
793
821
|
{ cause: e }
|
|
794
822
|
).toObject();
|
|
823
|
+
}
|
|
795
824
|
default:
|
|
796
825
|
return new NamedError.Unknown(
|
|
797
826
|
{ message: JSON.stringify(e) },
|
package/src/session/processor.ts
CHANGED
|
@@ -314,9 +314,28 @@ export namespace SessionProcessor {
|
|
|
314
314
|
const error = MessageV2.fromError(e, {
|
|
315
315
|
providerID: input.providerID,
|
|
316
316
|
});
|
|
317
|
-
|
|
317
|
+
|
|
318
|
+
// Check if error is retryable (APIError or SocketConnectionError)
|
|
319
|
+
const isRetryableAPIError =
|
|
320
|
+
error?.name === 'APIError' && error.data.isRetryable;
|
|
321
|
+
const isRetryableSocketError =
|
|
322
|
+
error?.name === 'SocketConnectionError' &&
|
|
323
|
+
error.data.isRetryable &&
|
|
324
|
+
attempt < SessionRetry.SOCKET_ERROR_MAX_RETRIES;
|
|
325
|
+
|
|
326
|
+
if (isRetryableAPIError || isRetryableSocketError) {
|
|
318
327
|
attempt++;
|
|
319
|
-
|
|
328
|
+
// Use socket-specific delay for socket errors
|
|
329
|
+
const delay =
|
|
330
|
+
error?.name === 'SocketConnectionError'
|
|
331
|
+
? SessionRetry.socketErrorDelay(attempt)
|
|
332
|
+
: SessionRetry.delay(error, attempt);
|
|
333
|
+
log.info(() => ({
|
|
334
|
+
message: 'retrying',
|
|
335
|
+
errorType: error?.name,
|
|
336
|
+
attempt,
|
|
337
|
+
delay,
|
|
338
|
+
}));
|
|
320
339
|
SessionStatus.set(input.sessionID, {
|
|
321
340
|
type: 'retry',
|
|
322
341
|
attempt,
|
package/src/session/prompt.ts
CHANGED
|
@@ -34,6 +34,7 @@ import { mergeDeep, pipe } from 'remeda';
|
|
|
34
34
|
import { ToolRegistry } from '../tool/registry';
|
|
35
35
|
import { Wildcard } from '../util/wildcard';
|
|
36
36
|
import { MCP } from '../mcp';
|
|
37
|
+
import { withTimeout } from '../util/timeout';
|
|
37
38
|
import { ReadTool } from '../tool/read';
|
|
38
39
|
import { ListTool } from '../tool/ls';
|
|
39
40
|
import { FileTime } from '../file/time';
|
|
@@ -877,7 +878,28 @@ export namespace SessionPrompt {
|
|
|
877
878
|
const execute = item.execute;
|
|
878
879
|
if (!execute) continue;
|
|
879
880
|
item.execute = async (args, opts) => {
|
|
880
|
-
|
|
881
|
+
// Get timeout for this specific tool
|
|
882
|
+
const timeout = await MCP.getToolTimeout(key);
|
|
883
|
+
|
|
884
|
+
// Wrap the execute call with timeout to prevent indefinite hangs
|
|
885
|
+
let result;
|
|
886
|
+
try {
|
|
887
|
+
result = await withTimeout(execute(args, opts), timeout);
|
|
888
|
+
} catch (error) {
|
|
889
|
+
// Check if it's a timeout error
|
|
890
|
+
if (
|
|
891
|
+
error instanceof Error &&
|
|
892
|
+
error.message.includes('timed out after')
|
|
893
|
+
) {
|
|
894
|
+
const timeoutSec = Math.round(timeout / 1000);
|
|
895
|
+
throw new Error(
|
|
896
|
+
`MCP tool "${key}" timed out after ${timeoutSec} seconds. ` +
|
|
897
|
+
`The operation did not complete within the configured timeout. ` +
|
|
898
|
+
`You can increase the timeout in the MCP server configuration using tool_call_timeout or tool_timeouts.`
|
|
899
|
+
);
|
|
900
|
+
}
|
|
901
|
+
throw error;
|
|
902
|
+
}
|
|
881
903
|
|
|
882
904
|
const textParts: string[] = [];
|
|
883
905
|
const attachments: MessageV2.FilePart[] = [];
|
package/src/session/retry.ts
CHANGED
|
@@ -6,6 +6,13 @@ export namespace SessionRetry {
|
|
|
6
6
|
export const RETRY_BACKOFF_FACTOR = 2;
|
|
7
7
|
export const RETRY_MAX_DELAY_NO_HEADERS = 30_000; // 30 seconds
|
|
8
8
|
|
|
9
|
+
// Socket connection error retry configuration
|
|
10
|
+
// Bun's fetch() has a known 10-second idle timeout issue
|
|
11
|
+
// See: https://github.com/oven-sh/bun/issues/14439
|
|
12
|
+
export const SOCKET_ERROR_MAX_RETRIES = 3;
|
|
13
|
+
export const SOCKET_ERROR_INITIAL_DELAY = 1000; // 1 second
|
|
14
|
+
export const SOCKET_ERROR_BACKOFF_FACTOR = 2;
|
|
15
|
+
|
|
9
16
|
export async function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
|
10
17
|
return new Promise((resolve, reject) => {
|
|
11
18
|
const timeout = setTimeout(resolve, ms);
|
|
@@ -53,4 +60,15 @@ export namespace SessionRetry {
|
|
|
53
60
|
RETRY_MAX_DELAY_NO_HEADERS
|
|
54
61
|
);
|
|
55
62
|
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Calculate delay for socket connection error retries.
|
|
66
|
+
* Uses exponential backoff: 1s, 2s, 4s, etc.
|
|
67
|
+
*/
|
|
68
|
+
export function socketErrorDelay(attempt: number): number {
|
|
69
|
+
return (
|
|
70
|
+
SOCKET_ERROR_INITIAL_DELAY *
|
|
71
|
+
Math.pow(SOCKET_ERROR_BACKOFF_FACTOR, attempt - 1)
|
|
72
|
+
);
|
|
73
|
+
}
|
|
56
74
|
}
|