@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.
- package/README.md +203 -193
- package/dist/index.js +22 -18
- package/dist/instructions.md +83 -60
- 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 -13
- 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 +13 -7
- package/dist/lib/gemini-schema.d.ts +3 -1
- package/dist/lib/gemini-schema.js +21 -19
- package/dist/lib/gemini.d.ts +1 -0
- package/dist/lib/gemini.js +264 -115
- package/dist/lib/model-config.d.ts +17 -0
- package/dist/lib/model-config.js +19 -0
- package/dist/lib/tool-factory.d.ts +21 -9
- package/dist/lib/tool-factory.js +277 -63
- package/dist/lib/tool-response.d.ts +9 -2
- package/dist/lib/tool-response.js +28 -11
- package/dist/lib/types.d.ts +7 -2
- package/dist/prompts/index.js +91 -3
- package/dist/resources/index.js +14 -10
- package/dist/schemas/inputs.d.ts +27 -15
- package/dist/schemas/inputs.js +60 -44
- package/dist/schemas/outputs.d.ts +130 -7
- package/dist/schemas/outputs.js +171 -74
- package/dist/server.d.ts +5 -1
- package/dist/server.js +39 -27
- 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 -41
- package/dist/tools/risk-score.d.ts +0 -2
- package/dist/tools/risk-score.js +0 -33
- package/dist/tools/suggest-patch.d.ts +0 -2
- 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 ===
|
|
35
|
-
result[key] =
|
|
46
|
+
if (key === 'type' && value === INTEGER_JSON_TYPE) {
|
|
47
|
+
result[key] = NUMBER_JSON_TYPE;
|
|
36
48
|
continue;
|
|
37
49
|
}
|
|
38
|
-
|
|
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
|
}
|
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
|
@@ -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 {
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
58
|
-
model: context
|
|
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
|
-
|
|
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
|
-
|
|
173
|
+
const nestedRecord = asRecord(nested);
|
|
174
|
+
if (!nestedRecord) {
|
|
69
175
|
return record;
|
|
70
176
|
}
|
|
71
|
-
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;
|
|
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
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
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
|
|
113
|
-
transientCode
|
|
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
|
|
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
|
|
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
|
-
...(
|
|
148
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
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. '
|
|
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
|
|
17
|
-
inputSchema: ZodRawShapeCompat;
|
|
18
|
-
/**
|
|
19
|
-
fullInputSchema
|
|
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. '
|
|
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;
|