@link-assistant/agent 0.6.3 → 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/auth/plugins.ts +538 -5
- 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/provider/google-cloudcode.ts +384 -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
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google Cloud Code API Client
|
|
3
|
+
*
|
|
4
|
+
* This module provides a client for Google's Cloud Code API (cloudcode-pa.googleapis.com),
|
|
5
|
+
* which is used by the official Gemini CLI for OAuth-authenticated requests.
|
|
6
|
+
*
|
|
7
|
+
* The Cloud Code API:
|
|
8
|
+
* 1. Accepts `cloud-platform` OAuth scope (unlike generativelanguage.googleapis.com)
|
|
9
|
+
* 2. Handles subscription tier validation (FREE, STANDARD, etc.)
|
|
10
|
+
* 3. Proxies requests to the Generative Language API internally
|
|
11
|
+
*
|
|
12
|
+
* @see https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/code_assist/server.ts
|
|
13
|
+
* @see https://github.com/link-assistant/agent/issues/100
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { Log } from '../util/log';
|
|
17
|
+
import { Auth } from '../auth';
|
|
18
|
+
|
|
19
|
+
const log = Log.create({ service: 'google-cloudcode' });
|
|
20
|
+
|
|
21
|
+
// Cloud Code API endpoints (from gemini-cli)
|
|
22
|
+
const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com';
|
|
23
|
+
const CODE_ASSIST_API_VERSION = 'v1internal';
|
|
24
|
+
|
|
25
|
+
// Google OAuth endpoints
|
|
26
|
+
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
|
27
|
+
const GOOGLE_OAUTH_CLIENT_ID =
|
|
28
|
+
'681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com';
|
|
29
|
+
const GOOGLE_OAUTH_CLIENT_SECRET = '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';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* User tier from Cloud Code API
|
|
33
|
+
*/
|
|
34
|
+
export enum UserTierId {
|
|
35
|
+
FREE = 'FREE',
|
|
36
|
+
STANDARD = 'STANDARD',
|
|
37
|
+
LEGACY = 'LEGACY',
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Cloud Code API request format
|
|
42
|
+
*/
|
|
43
|
+
interface CloudCodeRequest {
|
|
44
|
+
model: string;
|
|
45
|
+
project?: string;
|
|
46
|
+
user_prompt_id?: string;
|
|
47
|
+
request: {
|
|
48
|
+
contents: Array<{
|
|
49
|
+
role: string;
|
|
50
|
+
parts: Array<{ text?: string; [key: string]: unknown }>;
|
|
51
|
+
}>;
|
|
52
|
+
systemInstruction?: {
|
|
53
|
+
role: string;
|
|
54
|
+
parts: Array<{ text?: string }>;
|
|
55
|
+
};
|
|
56
|
+
tools?: unknown[];
|
|
57
|
+
toolConfig?: unknown;
|
|
58
|
+
generationConfig?: {
|
|
59
|
+
temperature?: number;
|
|
60
|
+
topP?: number;
|
|
61
|
+
topK?: number;
|
|
62
|
+
maxOutputTokens?: number;
|
|
63
|
+
candidateCount?: number;
|
|
64
|
+
stopSequences?: string[];
|
|
65
|
+
responseMimeType?: string;
|
|
66
|
+
responseSchema?: unknown;
|
|
67
|
+
thinkingConfig?: {
|
|
68
|
+
thinkingBudget?: number;
|
|
69
|
+
};
|
|
70
|
+
};
|
|
71
|
+
safetySettings?: unknown[];
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Cloud Code API response format
|
|
77
|
+
*/
|
|
78
|
+
interface CloudCodeResponse {
|
|
79
|
+
response: {
|
|
80
|
+
candidates: Array<{
|
|
81
|
+
content: {
|
|
82
|
+
role: string;
|
|
83
|
+
parts: Array<{
|
|
84
|
+
text?: string;
|
|
85
|
+
thought?: boolean;
|
|
86
|
+
functionCall?: unknown;
|
|
87
|
+
functionResponse?: unknown;
|
|
88
|
+
}>;
|
|
89
|
+
};
|
|
90
|
+
finishReason?: string;
|
|
91
|
+
safetyRatings?: unknown[];
|
|
92
|
+
}>;
|
|
93
|
+
usageMetadata?: {
|
|
94
|
+
promptTokenCount?: number;
|
|
95
|
+
candidatesTokenCount?: number;
|
|
96
|
+
totalTokenCount?: number;
|
|
97
|
+
thoughtsTokenCount?: number;
|
|
98
|
+
cachedContentTokenCount?: number;
|
|
99
|
+
};
|
|
100
|
+
modelVersion?: string;
|
|
101
|
+
};
|
|
102
|
+
traceId?: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Load Code Assist response (for user setup)
|
|
107
|
+
*/
|
|
108
|
+
interface LoadCodeAssistResponse {
|
|
109
|
+
cloudaicompanionProject?: string;
|
|
110
|
+
currentTier?: {
|
|
111
|
+
id: UserTierId;
|
|
112
|
+
name: string;
|
|
113
|
+
description: string;
|
|
114
|
+
};
|
|
115
|
+
allowedTiers?: Array<{
|
|
116
|
+
id: UserTierId;
|
|
117
|
+
name: string;
|
|
118
|
+
description: string;
|
|
119
|
+
isDefault?: boolean;
|
|
120
|
+
userDefinedCloudaicompanionProject?: boolean;
|
|
121
|
+
}>;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Onboard user response (for user setup)
|
|
126
|
+
*/
|
|
127
|
+
interface OnboardUserResponse {
|
|
128
|
+
done: boolean;
|
|
129
|
+
response?: {
|
|
130
|
+
cloudaicompanionProject?: {
|
|
131
|
+
id: string;
|
|
132
|
+
};
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Google Cloud Code API client
|
|
138
|
+
*
|
|
139
|
+
* This client implements the same API used by the official Gemini CLI.
|
|
140
|
+
* It wraps OAuth authentication and provides methods for:
|
|
141
|
+
* - Setting up user (onboarding to FREE/STANDARD tier)
|
|
142
|
+
* - Generating content (streaming and non-streaming)
|
|
143
|
+
* - Counting tokens
|
|
144
|
+
*/
|
|
145
|
+
export class CloudCodeClient {
|
|
146
|
+
private accessToken: string;
|
|
147
|
+
private refreshToken: string;
|
|
148
|
+
private tokenExpiry: number;
|
|
149
|
+
private projectId?: string;
|
|
150
|
+
private userTier?: UserTierId;
|
|
151
|
+
|
|
152
|
+
constructor(
|
|
153
|
+
private auth: {
|
|
154
|
+
access: string;
|
|
155
|
+
refresh: string;
|
|
156
|
+
expires: number;
|
|
157
|
+
},
|
|
158
|
+
projectId?: string
|
|
159
|
+
) {
|
|
160
|
+
this.accessToken = auth.access;
|
|
161
|
+
this.refreshToken = auth.refresh;
|
|
162
|
+
this.tokenExpiry = auth.expires;
|
|
163
|
+
this.projectId = projectId;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Refresh the OAuth access token if expired
|
|
168
|
+
*/
|
|
169
|
+
private async ensureValidToken(): Promise<void> {
|
|
170
|
+
const FIVE_MIN_MS = 5 * 60 * 1000;
|
|
171
|
+
if (this.tokenExpiry > Date.now() + FIVE_MIN_MS) {
|
|
172
|
+
return; // Token is still valid
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
log.info(() => ({
|
|
176
|
+
message: 'refreshing google oauth token for cloud code',
|
|
177
|
+
}));
|
|
178
|
+
|
|
179
|
+
const response = await fetch(GOOGLE_TOKEN_URL, {
|
|
180
|
+
method: 'POST',
|
|
181
|
+
headers: {
|
|
182
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
183
|
+
},
|
|
184
|
+
body: new URLSearchParams({
|
|
185
|
+
client_id: GOOGLE_OAUTH_CLIENT_ID,
|
|
186
|
+
client_secret: GOOGLE_OAUTH_CLIENT_SECRET,
|
|
187
|
+
refresh_token: this.refreshToken,
|
|
188
|
+
grant_type: 'refresh_token',
|
|
189
|
+
}),
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
if (!response.ok) {
|
|
193
|
+
throw new Error(`Token refresh failed: ${response.status}`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const json = await response.json();
|
|
197
|
+
this.accessToken = json.access_token;
|
|
198
|
+
this.tokenExpiry = Date.now() + json.expires_in * 1000;
|
|
199
|
+
|
|
200
|
+
// Update stored auth
|
|
201
|
+
await Auth.set('google', {
|
|
202
|
+
type: 'oauth',
|
|
203
|
+
refresh: this.refreshToken,
|
|
204
|
+
access: this.accessToken,
|
|
205
|
+
expires: this.tokenExpiry,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Make an authenticated request to the Cloud Code API
|
|
211
|
+
*/
|
|
212
|
+
private async request<T>(
|
|
213
|
+
method: string,
|
|
214
|
+
body: unknown,
|
|
215
|
+
options: { stream?: boolean } = {}
|
|
216
|
+
): Promise<T | Response> {
|
|
217
|
+
await this.ensureValidToken();
|
|
218
|
+
|
|
219
|
+
const url = `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:${method}`;
|
|
220
|
+
|
|
221
|
+
const response = await fetch(url, {
|
|
222
|
+
method: 'POST',
|
|
223
|
+
headers: {
|
|
224
|
+
'Content-Type': 'application/json',
|
|
225
|
+
Authorization: `Bearer ${this.accessToken}`,
|
|
226
|
+
'x-goog-api-client': 'agent/0.6.3',
|
|
227
|
+
},
|
|
228
|
+
body: JSON.stringify(body),
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
if (!response.ok) {
|
|
232
|
+
const errorText = await response.text();
|
|
233
|
+
log.error(() => ({
|
|
234
|
+
message: 'cloud code api error',
|
|
235
|
+
status: response.status,
|
|
236
|
+
error: errorText,
|
|
237
|
+
}));
|
|
238
|
+
throw new Error(
|
|
239
|
+
`Cloud Code API error: ${response.status} - ${errorText}`
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (options.stream) {
|
|
244
|
+
return response;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return response.json() as Promise<T>;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Load code assist to check user tier and project
|
|
252
|
+
*/
|
|
253
|
+
async loadCodeAssist(): Promise<LoadCodeAssistResponse> {
|
|
254
|
+
const body = {
|
|
255
|
+
cloudaicompanionProject: this.projectId,
|
|
256
|
+
metadata: {
|
|
257
|
+
ideType: 'IDE_UNSPECIFIED',
|
|
258
|
+
platform: 'PLATFORM_UNSPECIFIED',
|
|
259
|
+
pluginType: 'GEMINI',
|
|
260
|
+
duetProject: this.projectId,
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
return this.request<LoadCodeAssistResponse>('loadCodeAssist', body);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Onboard user to a tier (FREE or STANDARD)
|
|
269
|
+
*/
|
|
270
|
+
async onboardUser(tierId: UserTierId): Promise<OnboardUserResponse> {
|
|
271
|
+
const body = {
|
|
272
|
+
tierId,
|
|
273
|
+
cloudaicompanionProject:
|
|
274
|
+
tierId === UserTierId.FREE ? undefined : this.projectId,
|
|
275
|
+
metadata: {
|
|
276
|
+
ideType: 'IDE_UNSPECIFIED',
|
|
277
|
+
platform: 'PLATFORM_UNSPECIFIED',
|
|
278
|
+
pluginType: 'GEMINI',
|
|
279
|
+
...(tierId !== UserTierId.FREE && { duetProject: this.projectId }),
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
return this.request<OnboardUserResponse>('onboardUser', body);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Setup user - check tier and onboard if necessary
|
|
288
|
+
*/
|
|
289
|
+
async setupUser(): Promise<{ projectId?: string; userTier: UserTierId }> {
|
|
290
|
+
const loadRes = await this.loadCodeAssist();
|
|
291
|
+
|
|
292
|
+
// If user already has a tier, use it
|
|
293
|
+
if (loadRes.currentTier) {
|
|
294
|
+
this.userTier = loadRes.currentTier.id;
|
|
295
|
+
this.projectId = loadRes.cloudaicompanionProject || this.projectId;
|
|
296
|
+
return {
|
|
297
|
+
projectId: this.projectId,
|
|
298
|
+
userTier: this.userTier,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Find the default tier to onboard to
|
|
303
|
+
let targetTier = UserTierId.FREE;
|
|
304
|
+
for (const tier of loadRes.allowedTiers || []) {
|
|
305
|
+
if (tier.isDefault) {
|
|
306
|
+
targetTier = tier.id;
|
|
307
|
+
break;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
log.info(() => ({ message: 'onboarding user', tier: targetTier }));
|
|
312
|
+
|
|
313
|
+
// Poll onboardUser until done
|
|
314
|
+
let lroRes = await this.onboardUser(targetTier);
|
|
315
|
+
while (!lroRes.done) {
|
|
316
|
+
await new Promise((resolve) => setTimeout(resolve, 5000));
|
|
317
|
+
lroRes = await this.onboardUser(targetTier);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
this.userTier = targetTier;
|
|
321
|
+
if (lroRes.response?.cloudaicompanionProject?.id) {
|
|
322
|
+
this.projectId = lroRes.response.cloudaicompanionProject.id;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return {
|
|
326
|
+
projectId: this.projectId,
|
|
327
|
+
userTier: this.userTier,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Generate content (non-streaming)
|
|
333
|
+
*/
|
|
334
|
+
async generateContent(request: CloudCodeRequest): Promise<CloudCodeResponse> {
|
|
335
|
+
return this.request<CloudCodeResponse>('generateContent', request);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Generate content (streaming)
|
|
340
|
+
*/
|
|
341
|
+
async generateContentStream(request: CloudCodeRequest): Promise<Response> {
|
|
342
|
+
return this.request<Response>('streamGenerateContent', request, {
|
|
343
|
+
stream: true,
|
|
344
|
+
}) as Promise<Response>;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Get project ID (may be set during setup)
|
|
349
|
+
*/
|
|
350
|
+
getProjectId(): string | undefined {
|
|
351
|
+
return this.projectId;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Get user tier
|
|
356
|
+
*/
|
|
357
|
+
getUserTier(): UserTierId | undefined {
|
|
358
|
+
return this.userTier;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Create a Cloud Code client from stored Google OAuth credentials
|
|
364
|
+
*/
|
|
365
|
+
export async function createCloudCodeClient(): Promise<CloudCodeClient | null> {
|
|
366
|
+
const auth = await Auth.get('google');
|
|
367
|
+
if (!auth || auth.type !== 'oauth') {
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const projectId =
|
|
372
|
+
process.env['GOOGLE_CLOUD_PROJECT'] ||
|
|
373
|
+
process.env['GOOGLE_CLOUD_PROJECT_ID'];
|
|
374
|
+
|
|
375
|
+
return new CloudCodeClient(auth, projectId);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Check if Cloud Code API is available (user has OAuth credentials)
|
|
380
|
+
*/
|
|
381
|
+
export async function isCloudCodeAvailable(): Promise<boolean> {
|
|
382
|
+
const auth = await Auth.get('google');
|
|
383
|
+
return auth?.type === 'oauth';
|
|
384
|
+
}
|
|
@@ -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
|
}
|