@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.
- package/README.md +203 -193
- package/dist/index.js +18 -15
- package/dist/instructions.md +83 -58
- package/dist/lib/context-budget.d.ts +8 -0
- package/dist/lib/context-budget.js +30 -0
- package/dist/lib/diff-budget.d.ts +3 -1
- package/dist/lib/diff-budget.js +16 -19
- package/dist/lib/diff-parser.d.ts +34 -0
- package/dist/lib/diff-parser.js +114 -0
- package/dist/lib/env-config.d.ts +5 -0
- package/dist/lib/env-config.js +24 -0
- package/dist/lib/errors.d.ts +1 -0
- package/dist/lib/errors.js +9 -6
- package/dist/lib/gemini-schema.d.ts +3 -1
- package/dist/lib/gemini-schema.js +18 -17
- package/dist/lib/gemini.d.ts +1 -0
- package/dist/lib/gemini.js +216 -111
- package/dist/lib/model-config.d.ts +17 -0
- package/dist/lib/model-config.js +19 -0
- package/dist/lib/tool-factory.d.ts +20 -8
- package/dist/lib/tool-factory.js +264 -67
- package/dist/lib/tool-response.d.ts +9 -2
- package/dist/lib/tool-response.js +29 -14
- package/dist/lib/types.d.ts +8 -3
- package/dist/prompts/index.js +35 -15
- package/dist/resources/index.js +10 -9
- package/dist/schemas/inputs.d.ts +27 -15
- package/dist/schemas/inputs.js +59 -21
- package/dist/schemas/outputs.d.ts +130 -7
- package/dist/schemas/outputs.js +170 -40
- package/dist/server.d.ts +5 -1
- package/dist/server.js +32 -24
- package/dist/tools/analyze-pr-impact.d.ts +2 -0
- package/dist/tools/analyze-pr-impact.js +46 -0
- package/dist/tools/generate-review-summary.d.ts +2 -0
- package/dist/tools/generate-review-summary.js +67 -0
- package/dist/tools/generate-test-plan.d.ts +2 -0
- package/dist/tools/generate-test-plan.js +56 -0
- package/dist/tools/index.js +10 -6
- package/dist/tools/inspect-code-quality.d.ts +4 -0
- package/dist/tools/inspect-code-quality.js +107 -0
- package/dist/tools/suggest-search-replace.d.ts +2 -0
- package/dist/tools/suggest-search-replace.js +46 -0
- package/package.json +3 -2
- package/dist/tools/review-diff.d.ts +0 -2
- package/dist/tools/review-diff.js +0 -42
- package/dist/tools/risk-score.d.ts +0 -2
- package/dist/tools/risk-score.js +0 -34
- package/dist/tools/suggest-patch.d.ts +0 -2
- 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 ===
|
|
38
|
-
result[key] =
|
|
46
|
+
if (key === 'type' && value === INTEGER_JSON_TYPE) {
|
|
47
|
+
result[key] = NUMBER_JSON_TYPE;
|
|
39
48
|
continue;
|
|
40
49
|
}
|
|
41
|
-
|
|
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
|
}
|
package/dist/lib/gemini.d.ts
CHANGED
|
@@ -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>;
|
package/dist/lib/gemini.js
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
100
|
+
if (debug.enabled) {
|
|
101
|
+
debug('%j', payload);
|
|
102
|
+
}
|
|
59
103
|
});
|
|
60
104
|
const geminiContext = new AsyncLocalStorage({
|
|
61
105
|
name: 'gemini_request',
|
|
62
|
-
defaultValue: {
|
|
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:
|
|
67
|
-
model:
|
|
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
|
-
|
|
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
|
-
|
|
173
|
+
const nestedRecord = asRecord(nested);
|
|
174
|
+
if (!nestedRecord) {
|
|
110
175
|
return record;
|
|
111
176
|
}
|
|
112
|
-
return
|
|
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
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
171
|
-
...(
|
|
172
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
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. '
|
|
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
|
|
17
|
-
inputSchema: ZodRawShapeCompat;
|
|
18
|
-
/** Zod schema for validating the complete tool input
|
|
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. '
|
|
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;
|