@j0hanz/code-review-analyst-mcp 1.1.0 → 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 +18 -15
  3. package/dist/instructions.md +83 -58
  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 -19
  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 +9 -6
  14. package/dist/lib/gemini-schema.d.ts +3 -1
  15. package/dist/lib/gemini-schema.js +18 -17
  16. package/dist/lib/gemini.d.ts +1 -0
  17. package/dist/lib/gemini.js +216 -111
  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 +20 -8
  21. package/dist/lib/tool-factory.js +264 -67
  22. package/dist/lib/tool-response.d.ts +9 -2
  23. package/dist/lib/tool-response.js +29 -14
  24. package/dist/lib/types.d.ts +8 -3
  25. package/dist/prompts/index.js +35 -15
  26. package/dist/resources/index.js +10 -9
  27. package/dist/schemas/inputs.d.ts +27 -15
  28. package/dist/schemas/inputs.js +59 -21
  29. package/dist/schemas/outputs.d.ts +130 -7
  30. package/dist/schemas/outputs.js +170 -40
  31. package/dist/server.d.ts +5 -1
  32. package/dist/server.js +32 -24
  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 -42
  47. package/dist/tools/risk-score.d.ts +0 -2
  48. package/dist/tools/risk-score.js +0 -34
  49. package/dist/tools/suggest-patch.d.ts +0 -2
  50. package/dist/tools/suggest-patch.js +0 -35
@@ -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,9 +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';
18
14
  function isJsonRecord(value) {
19
15
  return typeof value === 'object' && value !== null && !Array.isArray(value);
20
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
+ }
21
30
  /**
22
31
  * Recursively strips value-range constraints (`min*`, `max*`, `multipleOf`)
23
32
  * from a JSON Schema object and converts `"type": "integer"` to
@@ -34,19 +43,11 @@ export function stripJsonSchemaConstraints(schema) {
34
43
  continue;
35
44
  // Relax integer → number so Gemini is not forced into integer-only
36
45
  // output; the stricter result schema still validates integrality.
37
- if (key === 'type' && value === 'integer') {
38
- result[key] = 'number';
46
+ if (key === 'type' && value === INTEGER_JSON_TYPE) {
47
+ result[key] = NUMBER_JSON_TYPE;
39
48
  continue;
40
49
  }
41
- if (Array.isArray(value)) {
42
- result[key] = value.map((item) => isJsonRecord(item) ? stripJsonSchemaConstraints(item) : item);
43
- }
44
- else if (isJsonRecord(value)) {
45
- result[key] = stripJsonSchemaConstraints(value);
46
- }
47
- else {
48
- result[key] = value;
49
- }
50
+ result[key] = stripConstraintValue(value);
50
51
  }
51
52
  return result;
52
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>;
@@ -5,7 +5,8 @@ import { performance } from 'node:perf_hooks';
5
5
  import { setTimeout as sleep } from 'node:timers/promises';
6
6
  import { debuglog } from 'node:util';
7
7
  import { GoogleGenAI, HarmBlockThreshold, HarmCategory } from '@google/genai';
8
- import { getErrorMessage } from './errors.js';
8
+ import { createCachedEnvInt } from './env-config.js';
9
+ import { getErrorMessage, RETRYABLE_UPSTREAM_ERROR_PATTERN } from './errors.js';
9
10
  // Lazy-cached: first call happens after parseCommandLineArgs() sets GEMINI_MODEL.
10
11
  let _defaultModel;
11
12
  function getDefaultModel() {
@@ -16,13 +17,18 @@ function getDefaultModel() {
16
17
  return value;
17
18
  }
18
19
  const DEFAULT_MAX_RETRIES = 1;
19
- const DEFAULT_TIMEOUT_MS = 45_000;
20
+ const DEFAULT_TIMEOUT_MS = 60_000;
20
21
  const DEFAULT_MAX_OUTPUT_TOKENS = 16_384;
21
22
  const RETRY_DELAY_BASE_MS = 300;
22
23
  const RETRY_DELAY_MAX_MS = 5_000;
23
24
  const RETRY_JITTER_RATIO = 0.2;
24
25
  const DEFAULT_SAFETY_THRESHOLD = HarmBlockThreshold.BLOCK_NONE;
26
+ const UNKNOWN_REQUEST_CONTEXT_VALUE = 'unknown';
25
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;
26
32
  const RETRYABLE_TRANSIENT_CODES = new Set([
27
33
  'RESOURCE_EXHAUSTED',
28
34
  'UNAVAILABLE',
@@ -30,6 +36,12 @@ const RETRYABLE_TRANSIENT_CODES = new Set([
30
36
  'INTERNAL',
31
37
  'ABORTED',
32
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
+ ];
33
45
  const numberFormatter = new Intl.NumberFormat('en-US');
34
46
  function formatNumber(value) {
35
47
  return numberFormatter.format(value);
@@ -40,32 +52,71 @@ const SAFETY_THRESHOLD_BY_NAME = {
40
52
  BLOCK_MEDIUM_AND_ABOVE: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE,
41
53
  BLOCK_LOW_AND_ABOVE: HarmBlockThreshold.BLOCK_LOW_AND_ABOVE,
42
54
  };
55
+ let cachedSafetyThresholdEnv;
56
+ let cachedSafetyThreshold = DEFAULT_SAFETY_THRESHOLD;
57
+ const safetySettingsCache = new Map();
43
58
  function getSafetyThreshold() {
44
59
  const threshold = process.env.GEMINI_HARM_BLOCK_THRESHOLD;
60
+ if (threshold === cachedSafetyThresholdEnv) {
61
+ return cachedSafetyThreshold;
62
+ }
63
+ cachedSafetyThresholdEnv = threshold;
45
64
  if (!threshold) {
46
- return DEFAULT_SAFETY_THRESHOLD;
65
+ cachedSafetyThreshold = DEFAULT_SAFETY_THRESHOLD;
66
+ return cachedSafetyThreshold;
47
67
  }
48
68
  const normalizedThreshold = threshold.trim().toUpperCase();
49
69
  if (normalizedThreshold in SAFETY_THRESHOLD_BY_NAME) {
50
- return SAFETY_THRESHOLD_BY_NAME[normalizedThreshold];
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;
51
86
  }
52
- return DEFAULT_SAFETY_THRESHOLD;
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;
53
95
  }
54
96
  let cachedClient;
55
97
  export const geminiEvents = new EventEmitter();
56
98
  const debug = debuglog('gemini');
57
99
  geminiEvents.on('log', (payload) => {
58
- debug(JSON.stringify(payload));
100
+ if (debug.enabled) {
101
+ debug('%j', payload);
102
+ }
59
103
  });
60
104
  const geminiContext = new AsyncLocalStorage({
61
105
  name: 'gemini_request',
62
- defaultValue: { requestId: 'unknown', model: 'unknown' },
106
+ defaultValue: {
107
+ requestId: UNKNOWN_REQUEST_CONTEXT_VALUE,
108
+ model: UNKNOWN_REQUEST_CONTEXT_VALUE,
109
+ },
63
110
  });
64
111
  // Shared fallback avoids a fresh object allocation per logEvent call when outside a run context.
65
112
  const UNKNOWN_CONTEXT = {
66
- requestId: 'unknown',
67
- model: 'unknown',
113
+ requestId: UNKNOWN_REQUEST_CONTEXT_VALUE,
114
+ model: UNKNOWN_REQUEST_CONTEXT_VALUE,
68
115
  };
116
+ export function getCurrentRequestId() {
117
+ const context = geminiContext.getStore();
118
+ return context?.requestId ?? UNKNOWN_REQUEST_CONTEXT_VALUE;
119
+ }
69
120
  function getApiKey() {
70
121
  const apiKey = process.env.GEMINI_API_KEY ?? process.env.GOOGLE_API_KEY;
71
122
  if (!apiKey) {
@@ -92,6 +143,12 @@ function logEvent(event, details) {
92
143
  ...details,
93
144
  });
94
145
  }
146
+ function asRecord(value) {
147
+ if (typeof value !== 'object' || value === null) {
148
+ return undefined;
149
+ }
150
+ return value;
151
+ }
95
152
  async function safeCallOnLog(onLog, level, data) {
96
153
  try {
97
154
  await onLog?.(level, data);
@@ -100,30 +157,57 @@ async function safeCallOnLog(onLog, level, data) {
100
157
  // Log callbacks are best-effort; never fail the tool call.
101
158
  }
102
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
+ }
103
167
  function getNestedError(error) {
104
- if (!error || typeof error !== 'object') {
168
+ const record = asRecord(error);
169
+ if (!record) {
105
170
  return undefined;
106
171
  }
107
- const record = error;
108
172
  const nested = record.error;
109
- if (!nested || typeof nested !== 'object') {
173
+ const nestedRecord = asRecord(nested);
174
+ if (!nestedRecord) {
110
175
  return record;
111
176
  }
112
- 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;
113
194
  }
114
195
  function getNumericErrorCode(error) {
115
196
  const record = getNestedError(error);
116
197
  if (!record) {
117
198
  return undefined;
118
199
  }
119
- const candidates = [record.status, record.statusCode, record.code];
120
- for (const candidate of candidates) {
121
- if (typeof candidate === 'number' && Number.isFinite(candidate)) {
122
- return candidate;
123
- }
124
- if (typeof candidate === 'string' && /^\d+$/.test(candidate)) {
125
- return Number.parseInt(candidate, 10);
126
- }
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;
127
211
  }
128
212
  return undefined;
129
213
  }
@@ -132,11 +216,17 @@ function getTransientErrorCode(error) {
132
216
  if (!record) {
133
217
  return undefined;
134
218
  }
135
- const candidates = [record.code, record.status, record.statusText];
136
- for (const candidate of candidates) {
137
- if (typeof candidate === 'string' && candidate.trim().length > 0) {
138
- return candidate.trim().toUpperCase();
139
- }
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;
140
230
  }
141
231
  return undefined;
142
232
  }
@@ -151,7 +241,7 @@ function shouldRetry(error) {
151
241
  return true;
152
242
  }
153
243
  const message = getErrorMessage(error);
154
- return /(429|500|502|503|504|rate limit|unavailable|timeout|invalid json)/i.test(message);
244
+ return RETRYABLE_UPSTREAM_ERROR_PATTERN.test(message);
155
245
  }
156
246
  function getRetryDelayMs(attempt) {
157
247
  const exponentialDelay = RETRY_DELAY_BASE_MS * 2 ** attempt;
@@ -161,40 +251,36 @@ function getRetryDelayMs(attempt) {
161
251
  return Math.min(RETRY_DELAY_MAX_MS, boundedDelay + jitter);
162
252
  }
163
253
  function buildGenerationConfig(request, abortSignal) {
164
- 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());
165
259
  return {
166
260
  temperature: request.temperature ?? 0.2,
167
261
  maxOutputTokens: request.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS,
168
262
  responseMimeType: 'application/json',
169
263
  responseSchema: request.responseSchema,
170
- // Spread undefined instead of {} so no intermediate object is allocated when absent.
171
- ...(request.systemInstruction
172
- ? { systemInstruction: request.systemInstruction }
173
- : undefined),
174
- safetySettings: [
175
- {
176
- category: HarmCategory.HARM_CATEGORY_HATE_SPEECH,
177
- threshold: safetyThreshold,
178
- },
179
- {
180
- category: HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT,
181
- threshold: safetyThreshold,
182
- },
183
- {
184
- category: HarmCategory.HARM_CATEGORY_HARASSMENT,
185
- threshold: safetyThreshold,
186
- },
187
- {
188
- category: HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT,
189
- threshold: safetyThreshold,
190
- },
191
- ],
264
+ ...(systemInstruction ?? undefined),
265
+ ...(thinkingConfig ? { thinkingConfig } : undefined),
266
+ safetySettings,
192
267
  abortSignal,
193
268
  };
194
269
  }
195
270
  function combineSignals(signal, requestSignal) {
196
271
  return requestSignal ? AbortSignal.any([signal, requestSignal]) : signal;
197
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
+ }
198
284
  async function generateContentWithTimeout(request, model, timeoutMs) {
199
285
  const controller = new AbortController();
200
286
  const timeout = setTimeout(() => {
@@ -222,71 +308,90 @@ async function generateContentWithTimeout(request, model, timeoutMs) {
222
308
  clearTimeout(timeout);
223
309
  }
224
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
+ }
225
381
  export async function generateStructuredJson(request) {
226
382
  const model = request.model ?? getDefaultModel();
227
383
  const timeoutMs = request.timeoutMs ?? DEFAULT_TIMEOUT_MS;
228
384
  const maxRetries = request.maxRetries ?? DEFAULT_MAX_RETRIES;
229
385
  const { onLog } = request;
230
- return geminiContext.run({ requestId: nextRequestId(), model }, async () => {
231
- let lastError;
232
- for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
233
- const startedAt = performance.now();
234
- try {
235
- const response = await generateContentWithTimeout(request, model, timeoutMs);
236
- const latencyMs = Math.round(performance.now() - startedAt);
237
- logEvent('gemini_call', {
238
- attempt,
239
- latencyMs,
240
- usageMetadata: response.usageMetadata ?? null,
241
- });
242
- await safeCallOnLog(onLog, 'info', {
243
- event: 'gemini_call',
244
- attempt,
245
- latencyMs,
246
- usageMetadata: response.usageMetadata ?? null,
247
- });
248
- if (!response.text) {
249
- throw new Error('Gemini returned an empty response body.');
250
- }
251
- let parsed;
252
- try {
253
- parsed = JSON.parse(response.text);
254
- }
255
- catch (error) {
256
- throw new Error(`Model produced invalid JSON: ${getErrorMessage(error)}`);
257
- }
258
- return parsed;
259
- }
260
- catch (error) {
261
- lastError = error;
262
- const retryable = shouldRetry(error);
263
- if (attempt >= maxRetries || !retryable) {
264
- break;
265
- }
266
- const delayMs = getRetryDelayMs(attempt);
267
- logEvent('gemini_retry', {
268
- attempt,
269
- delayMs,
270
- reason: getErrorMessage(error),
271
- });
272
- await safeCallOnLog(onLog, 'warning', {
273
- event: 'gemini_retry',
274
- attempt,
275
- delayMs,
276
- reason: getErrorMessage(error),
277
- });
278
- await sleep(delayMs, undefined, { ref: false });
279
- }
280
- }
281
- logEvent('gemini_failure', {
282
- error: getErrorMessage(lastError),
283
- attempts: maxRetries + 1,
284
- });
285
- await safeCallOnLog(onLog, 'error', {
286
- event: 'gemini_failure',
287
- error: getErrorMessage(lastError),
288
- attempts: maxRetries + 1,
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);
289
392
  });
290
- throw new Error(`Gemini request failed after ${maxRetries + 1} attempts: ${getErrorMessage(lastError)}`, { cause: lastError });
291
- });
393
+ }
394
+ finally {
395
+ activeCalls -= 1;
396
+ }
292
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 schema shape for the tool input. Used by the MCP SDK to strip unknown fields before the handler runs. */
17
- inputSchema: ZodRawShapeCompat;
18
- /** Zod schema for validating the complete tool input, including all expected fields. This is used within the handler to validate the actual input shape after the MCP SDK has stripped unknown fields. */
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
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;