@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.
@@ -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) },
@@ -314,9 +314,28 @@ export namespace SessionProcessor {
314
314
  const error = MessageV2.fromError(e, {
315
315
  providerID: input.providerID,
316
316
  });
317
- if (error?.name === 'APIError' && error.data.isRetryable) {
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
- const delay = SessionRetry.delay(error, attempt);
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,
@@ -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
- const result = await execute(args, opts);
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[] = [];
@@ -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
  }