@j0hanz/code-review-analyst-mcp 1.0.3 → 1.2.0

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.
Files changed (50) hide show
  1. package/README.md +203 -193
  2. package/dist/index.js +22 -18
  3. package/dist/instructions.md +83 -60
  4. package/dist/lib/context-budget.d.ts +8 -0
  5. package/dist/lib/context-budget.js +30 -0
  6. package/dist/lib/diff-budget.d.ts +3 -1
  7. package/dist/lib/diff-budget.js +16 -13
  8. package/dist/lib/diff-parser.d.ts +34 -0
  9. package/dist/lib/diff-parser.js +114 -0
  10. package/dist/lib/env-config.d.ts +5 -0
  11. package/dist/lib/env-config.js +24 -0
  12. package/dist/lib/errors.d.ts +1 -0
  13. package/dist/lib/errors.js +13 -7
  14. package/dist/lib/gemini-schema.d.ts +3 -1
  15. package/dist/lib/gemini-schema.js +21 -19
  16. package/dist/lib/gemini.d.ts +1 -0
  17. package/dist/lib/gemini.js +264 -115
  18. package/dist/lib/model-config.d.ts +17 -0
  19. package/dist/lib/model-config.js +19 -0
  20. package/dist/lib/tool-factory.d.ts +21 -9
  21. package/dist/lib/tool-factory.js +277 -63
  22. package/dist/lib/tool-response.d.ts +9 -2
  23. package/dist/lib/tool-response.js +28 -11
  24. package/dist/lib/types.d.ts +7 -2
  25. package/dist/prompts/index.js +91 -3
  26. package/dist/resources/index.js +14 -10
  27. package/dist/schemas/inputs.d.ts +27 -15
  28. package/dist/schemas/inputs.js +60 -44
  29. package/dist/schemas/outputs.d.ts +130 -7
  30. package/dist/schemas/outputs.js +171 -74
  31. package/dist/server.d.ts +5 -1
  32. package/dist/server.js +39 -27
  33. package/dist/tools/analyze-pr-impact.d.ts +2 -0
  34. package/dist/tools/analyze-pr-impact.js +46 -0
  35. package/dist/tools/generate-review-summary.d.ts +2 -0
  36. package/dist/tools/generate-review-summary.js +67 -0
  37. package/dist/tools/generate-test-plan.d.ts +2 -0
  38. package/dist/tools/generate-test-plan.js +56 -0
  39. package/dist/tools/index.js +10 -6
  40. package/dist/tools/inspect-code-quality.d.ts +4 -0
  41. package/dist/tools/inspect-code-quality.js +107 -0
  42. package/dist/tools/suggest-search-replace.d.ts +2 -0
  43. package/dist/tools/suggest-search-replace.js +46 -0
  44. package/package.json +3 -2
  45. package/dist/tools/review-diff.d.ts +0 -2
  46. package/dist/tools/review-diff.js +0 -41
  47. package/dist/tools/risk-score.d.ts +0 -2
  48. package/dist/tools/risk-score.js +0 -33
  49. package/dist/tools/suggest-patch.d.ts +0 -2
  50. package/dist/tools/suggest-patch.js +0 -34
@@ -1,9 +1,3 @@
1
- /**
2
- * JSON Schema property keys that represent value-range or count constraints.
3
- * These are stripped when generating relaxed schemas for Gemini structured
4
- * output so the model is not over-constrained by bounds that the
5
- * application-level result schema enforces after parsing.
6
- */
7
1
  const CONSTRAINT_KEYS = new Set([
8
2
  'minLength',
9
3
  'maxLength',
@@ -15,6 +9,24 @@ const CONSTRAINT_KEYS = new Set([
15
9
  'maxItems',
16
10
  'multipleOf',
17
11
  ]);
12
+ const INTEGER_JSON_TYPE = 'integer';
13
+ const NUMBER_JSON_TYPE = 'number';
14
+ function isJsonRecord(value) {
15
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
16
+ }
17
+ function stripConstraintValue(value) {
18
+ if (Array.isArray(value)) {
19
+ const stripped = new Array(value.length);
20
+ for (let index = 0; index < value.length; index += 1) {
21
+ stripped[index] = stripConstraintValue(value[index]);
22
+ }
23
+ return stripped;
24
+ }
25
+ if (isJsonRecord(value)) {
26
+ return stripJsonSchemaConstraints(value);
27
+ }
28
+ return value;
29
+ }
18
30
  /**
19
31
  * Recursively strips value-range constraints (`min*`, `max*`, `multipleOf`)
20
32
  * from a JSON Schema object and converts `"type": "integer"` to
@@ -31,21 +43,11 @@ export function stripJsonSchemaConstraints(schema) {
31
43
  continue;
32
44
  // Relax integer → number so Gemini is not forced into integer-only
33
45
  // output; the stricter result schema still validates integrality.
34
- if (key === 'type' && value === 'integer') {
35
- result[key] = 'number';
46
+ if (key === 'type' && value === INTEGER_JSON_TYPE) {
47
+ result[key] = NUMBER_JSON_TYPE;
36
48
  continue;
37
49
  }
38
- if (Array.isArray(value)) {
39
- result[key] = value.map((item) => typeof item === 'object' && item !== null && !Array.isArray(item)
40
- ? stripJsonSchemaConstraints(item)
41
- : item);
42
- }
43
- else if (typeof value === 'object' && value !== null) {
44
- result[key] = stripJsonSchemaConstraints(value);
45
- }
46
- else {
47
- result[key] = value;
48
- }
50
+ result[key] = stripConstraintValue(value);
49
51
  }
50
52
  return result;
51
53
  }
@@ -2,5 +2,6 @@ import { EventEmitter } from 'node:events';
2
2
  import { GoogleGenAI } from '@google/genai';
3
3
  import type { GeminiStructuredRequest } from './types.js';
4
4
  export declare const geminiEvents: EventEmitter<[never]>;
5
+ export declare function getCurrentRequestId(): string;
5
6
  export declare function setClientForTesting(client: GoogleGenAI): void;
6
7
  export declare function generateStructuredJson(request: GeminiStructuredRequest): Promise<unknown>;
@@ -3,18 +3,45 @@ import { randomInt, randomUUID } from 'node:crypto';
3
3
  import { EventEmitter } from 'node:events';
4
4
  import { performance } from 'node:perf_hooks';
5
5
  import { setTimeout as sleep } from 'node:timers/promises';
6
+ import { debuglog } from 'node:util';
6
7
  import { GoogleGenAI, HarmBlockThreshold, HarmCategory } from '@google/genai';
7
- import { getErrorMessage } from './errors.js';
8
+ import { createCachedEnvInt } from './env-config.js';
9
+ import { getErrorMessage, RETRYABLE_UPSTREAM_ERROR_PATTERN } from './errors.js';
10
+ // Lazy-cached: first call happens after parseCommandLineArgs() sets GEMINI_MODEL.
11
+ let _defaultModel;
8
12
  function getDefaultModel() {
9
- return process.env.GEMINI_MODEL ?? 'gemini-2.5-flash';
13
+ if (_defaultModel !== undefined)
14
+ return _defaultModel;
15
+ const value = process.env.GEMINI_MODEL ?? 'gemini-2.5-flash';
16
+ _defaultModel = value;
17
+ return value;
10
18
  }
11
19
  const DEFAULT_MAX_RETRIES = 1;
12
- const DEFAULT_TIMEOUT_MS = 45_000;
20
+ const DEFAULT_TIMEOUT_MS = 60_000;
13
21
  const DEFAULT_MAX_OUTPUT_TOKENS = 16_384;
14
22
  const RETRY_DELAY_BASE_MS = 300;
15
23
  const RETRY_DELAY_MAX_MS = 5_000;
16
24
  const RETRY_JITTER_RATIO = 0.2;
17
25
  const DEFAULT_SAFETY_THRESHOLD = HarmBlockThreshold.BLOCK_NONE;
26
+ const UNKNOWN_REQUEST_CONTEXT_VALUE = 'unknown';
27
+ const RETRYABLE_NUMERIC_CODES = new Set([429, 500, 502, 503, 504]);
28
+ const maxConcurrentCallsConfig = createCachedEnvInt('MAX_CONCURRENT_CALLS', 10);
29
+ const concurrencyWaitMsConfig = createCachedEnvInt('MAX_CONCURRENT_CALLS_WAIT_MS', 2_000);
30
+ const concurrencyPollMsConfig = createCachedEnvInt('MAX_CONCURRENT_CALLS_POLL_MS', 25);
31
+ let activeCalls = 0;
32
+ const RETRYABLE_TRANSIENT_CODES = new Set([
33
+ 'RESOURCE_EXHAUSTED',
34
+ 'UNAVAILABLE',
35
+ 'DEADLINE_EXCEEDED',
36
+ 'INTERNAL',
37
+ 'ABORTED',
38
+ ]);
39
+ const SAFETY_CATEGORIES = [
40
+ HarmCategory.HARM_CATEGORY_HATE_SPEECH,
41
+ HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT,
42
+ HarmCategory.HARM_CATEGORY_HARASSMENT,
43
+ HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT,
44
+ ];
18
45
  const numberFormatter = new Intl.NumberFormat('en-US');
19
46
  function formatNumber(value) {
20
47
  return numberFormatter.format(value);
@@ -25,14 +52,71 @@ const SAFETY_THRESHOLD_BY_NAME = {
25
52
  BLOCK_MEDIUM_AND_ABOVE: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE,
26
53
  BLOCK_LOW_AND_ABOVE: HarmBlockThreshold.BLOCK_LOW_AND_ABOVE,
27
54
  };
55
+ let cachedSafetyThresholdEnv;
56
+ let cachedSafetyThreshold = DEFAULT_SAFETY_THRESHOLD;
57
+ const safetySettingsCache = new Map();
58
+ function getSafetyThreshold() {
59
+ const threshold = process.env.GEMINI_HARM_BLOCK_THRESHOLD;
60
+ if (threshold === cachedSafetyThresholdEnv) {
61
+ return cachedSafetyThreshold;
62
+ }
63
+ cachedSafetyThresholdEnv = threshold;
64
+ if (!threshold) {
65
+ cachedSafetyThreshold = DEFAULT_SAFETY_THRESHOLD;
66
+ return cachedSafetyThreshold;
67
+ }
68
+ const normalizedThreshold = threshold.trim().toUpperCase();
69
+ if (normalizedThreshold in SAFETY_THRESHOLD_BY_NAME) {
70
+ cachedSafetyThreshold =
71
+ SAFETY_THRESHOLD_BY_NAME[normalizedThreshold];
72
+ return cachedSafetyThreshold;
73
+ }
74
+ cachedSafetyThreshold = DEFAULT_SAFETY_THRESHOLD;
75
+ return cachedSafetyThreshold;
76
+ }
77
+ function getThinkingConfig(thinkingBudget) {
78
+ return thinkingBudget !== undefined
79
+ ? { includeThoughts: true, thinkingBudget }
80
+ : undefined;
81
+ }
82
+ function getSafetySettings(threshold) {
83
+ const cached = safetySettingsCache.get(threshold);
84
+ if (cached) {
85
+ return cached;
86
+ }
87
+ const settings = new Array(SAFETY_CATEGORIES.length);
88
+ let index = 0;
89
+ for (const category of SAFETY_CATEGORIES) {
90
+ settings[index] = { category, threshold };
91
+ index += 1;
92
+ }
93
+ safetySettingsCache.set(threshold, settings);
94
+ return settings;
95
+ }
28
96
  let cachedClient;
29
97
  export const geminiEvents = new EventEmitter();
98
+ const debug = debuglog('gemini');
30
99
  geminiEvents.on('log', (payload) => {
31
- console.error(JSON.stringify(payload));
100
+ if (debug.enabled) {
101
+ debug('%j', payload);
102
+ }
32
103
  });
33
104
  const geminiContext = new AsyncLocalStorage({
34
105
  name: 'gemini_request',
106
+ defaultValue: {
107
+ requestId: UNKNOWN_REQUEST_CONTEXT_VALUE,
108
+ model: UNKNOWN_REQUEST_CONTEXT_VALUE,
109
+ },
35
110
  });
111
+ // Shared fallback avoids a fresh object allocation per logEvent call when outside a run context.
112
+ const UNKNOWN_CONTEXT = {
113
+ requestId: UNKNOWN_REQUEST_CONTEXT_VALUE,
114
+ model: UNKNOWN_REQUEST_CONTEXT_VALUE,
115
+ };
116
+ export function getCurrentRequestId() {
117
+ const context = geminiContext.getStore();
118
+ return context?.requestId ?? UNKNOWN_REQUEST_CONTEXT_VALUE;
119
+ }
36
120
  function getApiKey() {
37
121
  const apiKey = process.env.GEMINI_API_KEY ?? process.env.GOOGLE_API_KEY;
38
122
  if (!apiKey) {
@@ -51,38 +135,79 @@ function nextRequestId() {
51
135
  return randomUUID();
52
136
  }
53
137
  function logEvent(event, details) {
54
- const context = geminiContext.getStore();
138
+ const context = geminiContext.getStore() ?? UNKNOWN_CONTEXT;
55
139
  geminiEvents.emit('log', {
56
140
  event,
57
- requestId: context?.requestId ?? null,
58
- model: context?.model ?? null,
141
+ requestId: context.requestId,
142
+ model: context.model,
59
143
  ...details,
60
144
  });
61
145
  }
146
+ function asRecord(value) {
147
+ if (typeof value !== 'object' || value === null) {
148
+ return undefined;
149
+ }
150
+ return value;
151
+ }
152
+ async function safeCallOnLog(onLog, level, data) {
153
+ try {
154
+ await onLog?.(level, data);
155
+ }
156
+ catch {
157
+ // Log callbacks are best-effort; never fail the tool call.
158
+ }
159
+ }
160
+ async function emitGeminiLog(onLog, level, payload) {
161
+ logEvent(payload.event, payload.details);
162
+ await safeCallOnLog(onLog, level, {
163
+ event: payload.event,
164
+ ...payload.details,
165
+ });
166
+ }
62
167
  function getNestedError(error) {
63
- if (!error || typeof error !== 'object') {
168
+ const record = asRecord(error);
169
+ if (!record) {
64
170
  return undefined;
65
171
  }
66
- const record = error;
67
172
  const nested = record.error;
68
- if (!nested || typeof nested !== 'object') {
173
+ const nestedRecord = asRecord(nested);
174
+ if (!nestedRecord) {
69
175
  return record;
70
176
  }
71
- return nested;
177
+ return nestedRecord;
178
+ }
179
+ function toNumericCode(candidate) {
180
+ if (typeof candidate === 'number' && Number.isFinite(candidate)) {
181
+ return candidate;
182
+ }
183
+ if (typeof candidate === 'string' && /^\d+$/.test(candidate)) {
184
+ return Number.parseInt(candidate, 10);
185
+ }
186
+ return undefined;
187
+ }
188
+ function toUpperStringCode(candidate) {
189
+ if (typeof candidate !== 'string') {
190
+ return undefined;
191
+ }
192
+ const normalized = candidate.trim().toUpperCase();
193
+ return normalized.length > 0 ? normalized : undefined;
72
194
  }
73
195
  function getNumericErrorCode(error) {
74
196
  const record = getNestedError(error);
75
197
  if (!record) {
76
198
  return undefined;
77
199
  }
78
- const candidates = [record.status, record.statusCode, record.code];
79
- for (const candidate of candidates) {
80
- if (typeof candidate === 'number' && Number.isFinite(candidate)) {
81
- return candidate;
82
- }
83
- if (typeof candidate === 'string' && /^\d+$/.test(candidate)) {
84
- return Number.parseInt(candidate, 10);
85
- }
200
+ const fromStatus = toNumericCode(record.status);
201
+ if (fromStatus !== undefined) {
202
+ return fromStatus;
203
+ }
204
+ const fromStatusCode = toNumericCode(record.statusCode);
205
+ if (fromStatusCode !== undefined) {
206
+ return fromStatusCode;
207
+ }
208
+ const fromCode = toNumericCode(record.code);
209
+ if (fromCode !== undefined) {
210
+ return fromCode;
86
211
  }
87
212
  return undefined;
88
213
  }
@@ -91,33 +216,32 @@ function getTransientErrorCode(error) {
91
216
  if (!record) {
92
217
  return undefined;
93
218
  }
94
- const candidates = [record.code, record.status, record.statusText];
95
- for (const candidate of candidates) {
96
- if (typeof candidate === 'string' && candidate.trim().length > 0) {
97
- return candidate.trim().toUpperCase();
98
- }
219
+ const fromCode = toUpperStringCode(record.code);
220
+ if (fromCode !== undefined) {
221
+ return fromCode;
222
+ }
223
+ const fromStatus = toUpperStringCode(record.status);
224
+ if (fromStatus !== undefined) {
225
+ return fromStatus;
226
+ }
227
+ const fromStatusText = toUpperStringCode(record.statusText);
228
+ if (fromStatusText !== undefined) {
229
+ return fromStatusText;
99
230
  }
100
231
  return undefined;
101
232
  }
102
233
  function shouldRetry(error) {
103
234
  const numericCode = getNumericErrorCode(error);
104
- if (numericCode === 429 ||
105
- numericCode === 500 ||
106
- numericCode === 502 ||
107
- numericCode === 503 ||
108
- numericCode === 504) {
235
+ if (numericCode !== undefined && RETRYABLE_NUMERIC_CODES.has(numericCode)) {
109
236
  return true;
110
237
  }
111
238
  const transientCode = getTransientErrorCode(error);
112
- if (transientCode === 'RESOURCE_EXHAUSTED' ||
113
- transientCode === 'UNAVAILABLE' ||
114
- transientCode === 'DEADLINE_EXCEEDED' ||
115
- transientCode === 'INTERNAL' ||
116
- transientCode === 'ABORTED') {
239
+ if (transientCode !== undefined &&
240
+ RETRYABLE_TRANSIENT_CODES.has(transientCode)) {
117
241
  return true;
118
242
  }
119
243
  const message = getErrorMessage(error);
120
- return /(429|500|502|503|504|rate limit|unavailable|timeout|invalid json)/i.test(message);
244
+ return RETRYABLE_UPSTREAM_ERROR_PATTERN.test(message);
121
245
  }
122
246
  function getRetryDelayMs(attempt) {
123
247
  const exponentialDelay = RETRY_DELAY_BASE_MS * 2 ** attempt;
@@ -126,57 +250,44 @@ function getRetryDelayMs(attempt) {
126
250
  const jitter = randomInt(0, jitterWindow);
127
251
  return Math.min(RETRY_DELAY_MAX_MS, boundedDelay + jitter);
128
252
  }
129
- function getSafetyThreshold() {
130
- const threshold = process.env.GEMINI_HARM_BLOCK_THRESHOLD;
131
- if (!threshold) {
132
- return DEFAULT_SAFETY_THRESHOLD;
133
- }
134
- const normalizedThreshold = threshold.trim().toUpperCase();
135
- if (normalizedThreshold in SAFETY_THRESHOLD_BY_NAME) {
136
- return SAFETY_THRESHOLD_BY_NAME[normalizedThreshold];
137
- }
138
- return DEFAULT_SAFETY_THRESHOLD;
139
- }
140
253
  function buildGenerationConfig(request, abortSignal) {
141
- const safetyThreshold = getSafetyThreshold();
254
+ const systemInstruction = request.systemInstruction
255
+ ? { systemInstruction: request.systemInstruction }
256
+ : undefined;
257
+ const thinkingConfig = getThinkingConfig(request.thinkingBudget);
258
+ const safetySettings = getSafetySettings(getSafetyThreshold());
142
259
  return {
143
260
  temperature: request.temperature ?? 0.2,
144
261
  maxOutputTokens: request.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS,
145
262
  responseMimeType: 'application/json',
146
263
  responseSchema: request.responseSchema,
147
- ...(request.systemInstruction
148
- ? { systemInstruction: request.systemInstruction }
149
- : {}),
150
- safetySettings: [
151
- {
152
- category: HarmCategory.HARM_CATEGORY_HATE_SPEECH,
153
- threshold: safetyThreshold,
154
- },
155
- {
156
- category: HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT,
157
- threshold: safetyThreshold,
158
- },
159
- {
160
- category: HarmCategory.HARM_CATEGORY_HARASSMENT,
161
- threshold: safetyThreshold,
162
- },
163
- {
164
- category: HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT,
165
- threshold: safetyThreshold,
166
- },
167
- ],
264
+ ...(systemInstruction ?? undefined),
265
+ ...(thinkingConfig ? { thinkingConfig } : undefined),
266
+ safetySettings,
168
267
  abortSignal,
169
268
  };
170
269
  }
270
+ function combineSignals(signal, requestSignal) {
271
+ return requestSignal ? AbortSignal.any([signal, requestSignal]) : signal;
272
+ }
273
+ function parseStructuredResponse(responseText) {
274
+ if (!responseText) {
275
+ throw new Error('Gemini returned an empty response body.');
276
+ }
277
+ try {
278
+ return JSON.parse(responseText);
279
+ }
280
+ catch (error) {
281
+ throw new Error(`Model produced invalid JSON: ${getErrorMessage(error)}`);
282
+ }
283
+ }
171
284
  async function generateContentWithTimeout(request, model, timeoutMs) {
172
285
  const controller = new AbortController();
173
286
  const timeout = setTimeout(() => {
174
287
  controller.abort();
175
288
  }, timeoutMs);
176
289
  timeout.unref();
177
- const signal = request.signal
178
- ? AbortSignal.any([controller.signal, request.signal])
179
- : controller.signal;
290
+ const signal = combineSignals(controller.signal, request.signal);
180
291
  try {
181
292
  return await getClient().models.generateContent({
182
293
  model,
@@ -189,7 +300,7 @@ async function generateContentWithTimeout(request, model, timeoutMs) {
189
300
  throw new Error('Gemini request was cancelled.');
190
301
  }
191
302
  if (controller.signal.aborted) {
192
- throw new Error(`Gemini request timed out after ${formatNumber(timeoutMs)}ms.`);
303
+ throw new Error(`Gemini request timed out after ${formatNumber(timeoutMs)}ms.`, { cause: error });
193
304
  }
194
305
  throw error;
195
306
  }
@@ -197,52 +308,90 @@ async function generateContentWithTimeout(request, model, timeoutMs) {
197
308
  clearTimeout(timeout);
198
309
  }
199
310
  }
311
+ async function executeAttempt(request, model, timeoutMs, attempt, onLog) {
312
+ const startedAt = performance.now();
313
+ const response = await generateContentWithTimeout(request, model, timeoutMs);
314
+ const latencyMs = Math.round(performance.now() - startedAt);
315
+ await emitGeminiLog(onLog, 'info', {
316
+ event: 'gemini_call',
317
+ details: {
318
+ attempt,
319
+ latencyMs,
320
+ usageMetadata: response.usageMetadata ?? null,
321
+ },
322
+ });
323
+ return parseStructuredResponse(response.text);
324
+ }
325
+ async function waitBeforeRetry(attempt, error, onLog) {
326
+ const delayMs = getRetryDelayMs(attempt);
327
+ const reason = getErrorMessage(error);
328
+ await emitGeminiLog(onLog, 'warning', {
329
+ event: 'gemini_retry',
330
+ details: {
331
+ attempt,
332
+ delayMs,
333
+ reason,
334
+ },
335
+ });
336
+ await sleep(delayMs, undefined, { ref: false });
337
+ }
338
+ async function throwGeminiFailure(maxRetries, lastError, onLog) {
339
+ const attempts = maxRetries + 1;
340
+ const message = getErrorMessage(lastError);
341
+ await emitGeminiLog(onLog, 'error', {
342
+ event: 'gemini_failure',
343
+ details: {
344
+ error: message,
345
+ attempts,
346
+ },
347
+ });
348
+ throw new Error(`Gemini request failed after ${attempts} attempts: ${message}`, { cause: lastError });
349
+ }
350
+ async function runWithRetries(request, model, timeoutMs, maxRetries, onLog) {
351
+ let lastError;
352
+ for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
353
+ try {
354
+ return await executeAttempt(request, model, timeoutMs, attempt, onLog);
355
+ }
356
+ catch (error) {
357
+ lastError = error;
358
+ if (attempt >= maxRetries || !shouldRetry(error)) {
359
+ break;
360
+ }
361
+ await waitBeforeRetry(attempt, error, onLog);
362
+ }
363
+ }
364
+ return throwGeminiFailure(maxRetries, lastError, onLog);
365
+ }
366
+ async function waitForConcurrencySlot(limit, requestSignal) {
367
+ const waitLimitMs = concurrencyWaitMsConfig.get();
368
+ const pollMs = concurrencyPollMsConfig.get();
369
+ const startedAt = performance.now();
370
+ while (activeCalls >= limit) {
371
+ if (requestSignal?.aborted) {
372
+ throw new Error('Gemini request was cancelled.');
373
+ }
374
+ const elapsedMs = performance.now() - startedAt;
375
+ if (elapsedMs >= waitLimitMs) {
376
+ throw new Error(`Too many concurrent Gemini calls (limit: ${formatNumber(limit)}; waited ${formatNumber(waitLimitMs)}ms).`);
377
+ }
378
+ await sleep(pollMs, undefined, { ref: false });
379
+ }
380
+ }
200
381
  export async function generateStructuredJson(request) {
201
382
  const model = request.model ?? getDefaultModel();
202
383
  const timeoutMs = request.timeoutMs ?? DEFAULT_TIMEOUT_MS;
203
384
  const maxRetries = request.maxRetries ?? DEFAULT_MAX_RETRIES;
204
- return geminiContext.run({ requestId: nextRequestId(), model }, async () => {
205
- let lastError;
206
- for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
207
- const startedAt = performance.now();
208
- try {
209
- const response = await generateContentWithTimeout(request, model, timeoutMs);
210
- logEvent('gemini_call', {
211
- attempt,
212
- latencyMs: Math.round(performance.now() - startedAt),
213
- usageMetadata: response.usageMetadata ?? null,
214
- });
215
- if (!response.text) {
216
- throw new Error('Gemini returned an empty response body.');
217
- }
218
- let parsed;
219
- try {
220
- parsed = JSON.parse(response.text);
221
- }
222
- catch (error) {
223
- throw new Error(`Model produced invalid JSON: ${getErrorMessage(error)}`);
224
- }
225
- return parsed;
226
- }
227
- catch (error) {
228
- lastError = error;
229
- const retryable = shouldRetry(error);
230
- if (attempt >= maxRetries || !retryable) {
231
- break;
232
- }
233
- const delayMs = getRetryDelayMs(attempt);
234
- logEvent('gemini_retry', {
235
- attempt,
236
- delayMs,
237
- reason: getErrorMessage(error),
238
- });
239
- await sleep(delayMs, undefined, { ref: false });
240
- }
241
- }
242
- logEvent('gemini_failure', {
243
- error: getErrorMessage(lastError),
244
- attempts: maxRetries + 1,
385
+ const { onLog } = request;
386
+ const limit = maxConcurrentCallsConfig.get();
387
+ await waitForConcurrencySlot(limit, request.signal);
388
+ activeCalls += 1;
389
+ try {
390
+ return await geminiContext.run({ requestId: nextRequestId(), model }, async () => {
391
+ return runWithRetries(request, model, timeoutMs, maxRetries, onLog);
245
392
  });
246
- throw new Error(`Gemini request failed after ${maxRetries + 1} attempts: ${getErrorMessage(lastError)}`);
247
- });
393
+ }
394
+ finally {
395
+ activeCalls -= 1;
396
+ }
248
397
  }
@@ -0,0 +1,17 @@
1
+ /** Fast, cost-effective model for summarization and light analysis. */
2
+ export declare const FLASH_MODEL = "gemini-2.5-flash";
3
+ /** High-capability model for deep reasoning, quality inspection, and reliable code generation. */
4
+ export declare const PRO_MODEL = "gemini-2.5-pro";
5
+ /** Thinking budget (tokens) for Flash model thinking tasks (test plans, search/replace). */
6
+ export declare const FLASH_THINKING_BUDGET = 8192;
7
+ /** Thinking budget (tokens) for Pro model deep-analysis tasks (code quality inspection). */
8
+ export declare const PRO_THINKING_BUDGET = 16384;
9
+ /** Extended timeout for Pro model calls (ms). Pro thinks longer than Flash. */
10
+ export declare const DEFAULT_TIMEOUT_PRO_MS = 120000;
11
+ export declare const MODEL_TIMEOUT_MS: {
12
+ readonly defaultPro: 120000;
13
+ };
14
+ /** Default language hint when not specified by the user. Tells the model to auto-detect. */
15
+ export declare const DEFAULT_LANGUAGE = "detect";
16
+ /** Default test-framework hint when not specified by the user. Tells the model to auto-detect. */
17
+ export declare const DEFAULT_FRAMEWORK = "detect";
@@ -0,0 +1,19 @@
1
+ /** Fast, cost-effective model for summarization and light analysis. */
2
+ export const FLASH_MODEL = 'gemini-2.5-flash';
3
+ /** High-capability model for deep reasoning, quality inspection, and reliable code generation. */
4
+ export const PRO_MODEL = 'gemini-2.5-pro';
5
+ const FLASH_THINKING_BUDGET_VALUE = 8_192;
6
+ const PRO_THINKING_BUDGET_VALUE = 16_384;
7
+ /** Thinking budget (tokens) for Flash model thinking tasks (test plans, search/replace). */
8
+ export const FLASH_THINKING_BUDGET = FLASH_THINKING_BUDGET_VALUE;
9
+ /** Thinking budget (tokens) for Pro model deep-analysis tasks (code quality inspection). */
10
+ export const PRO_THINKING_BUDGET = PRO_THINKING_BUDGET_VALUE;
11
+ /** Extended timeout for Pro model calls (ms). Pro thinks longer than Flash. */
12
+ export const DEFAULT_TIMEOUT_PRO_MS = 120_000;
13
+ export const MODEL_TIMEOUT_MS = {
14
+ defaultPro: DEFAULT_TIMEOUT_PRO_MS,
15
+ };
16
+ /** Default language hint when not specified by the user. Tells the model to auto-detect. */
17
+ export const DEFAULT_LANGUAGE = 'detect';
18
+ /** Default test-framework hint when not specified by the user. Tells the model to auto-detect. */
19
+ export const DEFAULT_FRAMEWORK = 'detect';
@@ -6,26 +6,38 @@ export interface PromptParts {
6
6
  systemInstruction: string;
7
7
  prompt: string;
8
8
  }
9
- export interface StructuredToolTaskConfig<TInput extends object = Record<string, unknown>> {
10
- /** Tool name registered with the MCP server (e.g. 'review_diff'). */
9
+ export interface StructuredToolTaskConfig<TInput extends object = Record<string, unknown>, TResult extends object = Record<string, unknown>, TFinal extends TResult = TResult> {
10
+ /** Tool name registered with the MCP server (e.g. 'analyze_pr_impact'). */
11
11
  name: string;
12
12
  /** Human-readable title shown to clients. */
13
13
  title: string;
14
14
  /** Short description of the tool's purpose. */
15
15
  description: string;
16
- /** Zod shape object (e.g. `MySchema.shape`) used as the MCP input schema. */
17
- inputSchema: ZodRawShapeCompat;
18
- /** Full Zod schema for runtime input re-validation (rejects unknown fields). */
19
- fullInputSchema?: z.ZodType;
16
+ /** Zod schema or raw shape for MCP request validation at the transport boundary. */
17
+ inputSchema: z.ZodType<TInput> | ZodRawShapeCompat;
18
+ /** Zod schema for validating the complete tool input inside the handler. */
19
+ fullInputSchema: z.ZodType<TInput>;
20
20
  /** Zod schema for parsing and validating the Gemini structured response. */
21
- resultSchema: z.ZodType;
21
+ resultSchema: z.ZodType<TResult>;
22
22
  /** Optional Zod schema used specifically for Gemini response validation. */
23
23
  geminiSchema?: z.ZodType;
24
- /** Stable error code returned on failure (e.g. 'E_REVIEW_DIFF'). */
24
+ /** Stable error code returned on failure (e.g. 'E_INSPECT_QUALITY'). */
25
25
  errorCode: string;
26
+ /** Optional post-processing hook called after resultSchema.parse(). The return value replaces the parsed result. */
27
+ transformResult?: (input: TInput, result: TResult) => TFinal;
26
28
  /** Optional validation hook for input parameters. */
27
29
  validateInput?: (input: TInput) => Promise<ReturnType<typeof createErrorToolResponse> | undefined> | ReturnType<typeof createErrorToolResponse> | undefined;
30
+ /** Optional Gemini model to use (e.g. 'gemini-2.5-pro'). */
31
+ model?: string;
32
+ /** Optional thinking budget in tokens. */
33
+ thinkingBudget?: number;
34
+ /** Optional timeout in ms for the Gemini call. Defaults to 60,000 ms. Use DEFAULT_TIMEOUT_PRO_MS for Pro model calls. */
35
+ timeoutMs?: number;
36
+ /** Optional formatter for human-readable text output. */
37
+ formatOutput?: (result: TFinal) => string;
38
+ /** Optional context text used in progress messages. */
39
+ progressContext?: (input: TInput) => string;
28
40
  /** Builds the system instruction and user prompt from parsed tool input. */
29
41
  buildPrompt: (input: TInput) => PromptParts;
30
42
  }
31
- export declare function registerStructuredToolTask<TInput extends object>(server: McpServer, config: StructuredToolTaskConfig<TInput>): void;
43
+ export declare function registerStructuredToolTask<TInput extends object, TResult extends object = Record<string, unknown>, TFinal extends TResult = TResult>(server: McpServer, config: StructuredToolTaskConfig<TInput, TResult, TFinal>): void;