Skip to content

Commit f875c2e

Browse files
authored
Merge pull request #26 from CodeAnt-AI/feature/chinmay/agentic-review-batching
5-file batching, BulkRead tool, label preservation, cold-start retry
2 parents e554cb7 + 7a03852 commit f875c2e

8 files changed

Lines changed: 183 additions & 64 deletions

File tree

changelog.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# Changelog
22

3+
## [0.5.1] - 31/05/2026
4+
- AI code review increasing coverage
5+
36
## [0.4.12] - 19/05/2026
47
- Settings added
58

package-lock.json

Lines changed: 11 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "codeant-cli",
3-
"version": "0.5.0",
3+
"version": "0.5.1",
44
"description": "Code review CLI tool",
55
"type": "module",
66
"bin": {
@@ -52,6 +52,7 @@
5252
"posthog-node": "^5.28.5",
5353
"react": "^18.3.1",
5454
"smol-toml": "^1.6.1",
55+
"undici": "^6.26.0",
5556
"zod": "^3.25.76"
5657
},
5758
"devDependencies": {

src/reviewHeadless.js

Lines changed: 49 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -125,27 +125,34 @@ export async function runReviewHeadless(options = {}) {
125125
// ── Split into per-file requests ────────────────────────────────────
126126
const perFileRequests = ReviewApiHelper.splitIntoPerFileRequests(requestBody);
127127

128-
onProgress(`Analyzing ${perFileRequests.length} file${perFileRequests.length !== 1 ? 's' : ''} in parallel...`);
128+
const totalFiles = perFileRequests.reduce((n, r) => n + (r._filenames?.length || 0), 0);
129+
onProgress(`Analyzing ${totalFiles} file${totalFiles !== 1 ? 's' : ''}...`);
129130

130-
// ── Per-file agent turn loops (parallel, fault-tolerant) ─────────────
131+
// ── Per-batch agent turn loops (parallel, fault-tolerant) ────────────
132+
// Each batch covers up to 5 files; backend reviews the multi-file diff
133+
// in a single session (matches pragent's FileBatcher).
131134
const perFileResults = await Promise.all(
132135
perFileRequests.map(async (fileReq) => {
133-
const filename = fileReq._filename;
134-
delete fileReq._filename;
135-
136-
if (fileReq.file_contents?.[filename]) {
137-
fileReq.file_content = fileReq.file_contents[filename];
138-
fileReq.file_path = filename;
136+
const filenames = fileReq._filenames || [];
137+
delete fileReq._filenames;
138+
const label = filenames.join(', ');
139+
140+
// Single-file batch: still pass file_content/file_path so backend can
141+
// build head_file_str. Multi-file batches drop these (head_file_str
142+
// becomes empty; agent gathers context via tools instead).
143+
if (filenames.length === 1 && fileReq.file_contents?.[filenames[0]]) {
144+
fileReq.file_content = fileReq.file_contents[filenames[0]];
145+
fileReq.file_path = filenames[0];
139146
}
140147
delete fileReq.file_contents;
141148

142149
try {
143-
onProgress(`Reviewing ${filename}...`);
150+
onProgress(`Reviewing ${label}...`);
144151
const result = await runTurnLoop(fileReq, gitRoot, false);
145-
onProgress(`Done reviewing ${filename}`);
152+
onProgress(`Done reviewing ${label}`);
146153
return result;
147154
} catch (err) {
148-
console.error(`[error] Failed to review ${filename}: ${err.message}`);
155+
console.error(`[error] Failed to review ${label}: ${err.message}`);
149156
return { finalMessage: null, finalOutput: null };
150157
}
151158
})
@@ -158,7 +165,12 @@ export async function runReviewHeadless(options = {}) {
158165
output: perFileResults[i].finalOutput,
159166
})).filter(r => r.output?.code_suggestions?.length > 0);
160167

161-
onProgress(`${perFileWithSuggestions.length} file(s) have suggestions, running reflector...`);
168+
const filesWithSuggestions = new Set(
169+
perFileWithSuggestions.flatMap(r =>
170+
(r.output?.code_suggestions || []).map(s => (s.relevant_file || '').trim()).filter(Boolean)
171+
)
172+
).size;
173+
onProgress(`${filesWithSuggestions} file${filesWithSuggestions !== 1 ? 's' : ''} have suggestions, running reflector...`);
162174

163175
// ── Per-file reflector loops (parallel, fault-tolerant) ──────────────
164176
const reflectorResults = await Promise.all(
@@ -180,15 +192,34 @@ export async function runReviewHeadless(options = {}) {
180192
})
181193
);
182194

183-
// ── Parse results ───────────────────────────────────────────────────
184-
const issues = reflectorResults.flatMap(r =>
185-
(r.finalOutput?.code_suggestions || []).map((issue) => ({
195+
// ── Parse results, carrying generator labels through reflector ──────
196+
// Pragent's rejector schema drops `label`; preserve it by matching the
197+
// reflector's per-issue (file, start_line) back to the generator's output.
198+
const issues = reflectorResults.flatMap((r, i) => {
199+
const genSuggestions = perFileWithSuggestions[i]?.output?.code_suggestions || [];
200+
const norm = (v) => (typeof v === 'string' ? v.trim() : v);
201+
const labelFor = (issue) => {
202+
if (norm(issue.label)) return norm(issue.label);
203+
// Match by summary (pragent's primary strategy): rejector's
204+
// suggestion_summary (renamed to issue_content server-side) is
205+
// "Repeated from the input" — i.e. the generator's one_sentence_summary.
206+
const summary = norm(issue.issue_content);
207+
if (summary) {
208+
const exact = genSuggestions.find(g => norm(g.one_sentence_summary) === summary);
209+
if (norm(exact?.label)) return norm(exact.label);
210+
}
211+
// Fallback: first generator suggestion in the same file with a label.
212+
const file = norm(issue.relevant_file);
213+
const sameFile = genSuggestions.find(g => norm(g.relevant_file) === file && norm(g.label));
214+
return norm(sameFile?.label) || 'Code Quality';
215+
};
216+
return (r.finalOutput?.code_suggestions || []).map((issue) => ({
186217
issue_content: issue.issue_content || '',
187218
relevant_file: issue.relevant_file || 'Unknown',
188219
start_line: issue.start_line || 0,
189-
label: issue.label || 'Code Quality',
190-
}))
191-
);
220+
label: labelFor(issue),
221+
}));
222+
});
192223

193224
const labelCounts = {};
194225
for (const i of issues) { labelCounts[i.label] = (labelCounts[i.label] || 0) + 1; }

src/tools/bulkReadTool.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { readTool } from './readTool.js';
2+
3+
const MAX_FILES = 10;
4+
5+
export async function bulkReadTool(args, cwd) {
6+
const files = Array.isArray(args.files) ? args.files.slice(0, MAX_FILES) : [];
7+
if (!files.length) return 'Error: BulkRead requires a non-empty `files` array';
8+
9+
const parts = await Promise.all(
10+
files.map(async (f) => {
11+
try {
12+
const content = await readTool(f, cwd);
13+
return `===== ${f.file_path} =====\n${content}`;
14+
} catch (err) {
15+
return `===== ${f.file_path} =====\nError: ${err.message}`;
16+
}
17+
})
18+
);
19+
return parts.join('\n\n');
20+
}

src/tools/executeTool.js

Lines changed: 30 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,28 +2,41 @@ import { readTool } from './readTool.js';
22
import { globTool } from './globTool.js';
33
import { grepTool } from './grepTool.js';
44
import { lsTool } from './lsTool.js';
5+
import { bulkReadTool } from './bulkReadTool.js';
56

6-
export async function executeTool(toolCall, cwd) {
7-
const { name, args } = toolCall;
8-
9-
try {
10-
if (name === 'Read') {
11-
return await readTool(args, cwd);
12-
}
7+
// Pragent prompts instruct the model to use absolute paths rooted at /workspace.
8+
// The CLI works relative to the user's cwd, so strip that prefix (and any other
9+
// leading slash) before handing off to the tool implementations.
10+
function stripWorkspace(p) {
11+
if (typeof p !== 'string') return p;
12+
return p.replace(/^\/workspace\/?/, '').replace(/^\/+/, '');
13+
}
1314

14-
if (name === 'Glob') {
15-
return await globTool(args, cwd);
16-
}
15+
function normalizeArgs(args) {
16+
if (!args || typeof args !== 'object') return args;
17+
const out = { ...args };
18+
if ('file_path' in out) out.file_path = stripWorkspace(out.file_path);
19+
if ('path' in out) out.path = stripWorkspace(out.path);
20+
if (Array.isArray(out.files)) {
21+
out.files = out.files.map((f) =>
22+
f && typeof f === 'object' ? { ...f, file_path: stripWorkspace(f.file_path) } : f
23+
);
24+
}
25+
return out;
26+
}
1727

18-
if (name === 'Grep') {
19-
return await grepTool(args, cwd);
20-
}
28+
export async function executeTool(toolCall, cwd) {
29+
const { name } = toolCall;
30+
const args = normalizeArgs(toolCall.args);
2131

22-
if (name === 'LS') {
23-
return await lsTool(args, cwd);
24-
}
32+
try {
33+
if (name === 'Read') return await readTool(args, cwd);
34+
if (name === 'Glob') return await globTool(args, cwd);
35+
if (name === 'Grep') return await grepTool(args, cwd);
36+
if (name === 'LS') return await lsTool(args, cwd);
37+
if (name === 'BulkRead') return await bulkReadTool(args, cwd);
2538

26-
// Bash tool intentionally removed to prevent arbitrary command execution
39+
// Bash intentionally not supported — see Extension/AgenticReview/tools.py.
2740
return `Unknown tool: ${name}`;
2841
} catch (err) {
2942
return `Error: ${err.message}`;

src/utils/fetchApi.js

Lines changed: 50 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,28 @@
1+
import { Agent, setGlobalDispatcher } from 'undici';
12
import { getConfigValue } from './config.js';
23
import { getBaseUrl } from './baseUrl.js';
34

5+
// Undici's default TCP connect timeout (10s) is too short for cold-start
6+
// Lambda / API-Gateway TLS handshakes under burst load (the headless review
7+
// fan-outs 8 requests at once). Bump to 60s globally + retry on transient
8+
// network errors so cold starts don't break the run.
9+
setGlobalDispatcher(
10+
new Agent({
11+
connect: { timeout: 60_000 },
12+
connections: 32,
13+
})
14+
);
15+
16+
const RETRYABLE_CAUSES = new Set([
17+
'UND_ERR_CONNECT_TIMEOUT',
18+
'UND_ERR_SOCKET',
19+
'ECONNRESET',
20+
'ETIMEDOUT',
21+
'ENOTFOUND',
22+
]);
23+
24+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
25+
426
const fetchApi = async (endpoint, method = 'GET', body = null) => {
527
const url = endpoint.startsWith('http') ? endpoint : `${getBaseUrl()}${endpoint}`;
628

@@ -21,25 +43,39 @@ const fetchApi = async (endpoint, method = 'GET', body = null) => {
2143
options.body = JSON.stringify(body);
2244
}
2345

24-
try {
25-
const response = await fetch(url, options);
26-
console.error('API Response Status:', response.status);
46+
// Retry transient network/cold-start failures up to 2 times.
47+
const MAX_ATTEMPTS = 3;
48+
let lastErr;
49+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
50+
try {
51+
const response = await fetch(url, options);
52+
console.error('API Response Status:', response.status);
2753

28-
if (response.status === 403) {
29-
throw new Error('Access denied (403). Please run `codeant logout` and then `codeant login` to re-authenticate.');
30-
}
54+
if (response.status === 403) {
55+
throw new Error('Access denied (403). Please run `codeant logout` and then `codeant login` to re-authenticate.');
56+
}
3157

32-
const data = await response.json();
58+
const data = await response.json();
3359

34-
if (!response.ok) {
35-
throw new Error(data.message || `HTTP error ${response.status}`);
36-
}
60+
if (!response.ok) {
61+
throw new Error(data.message || `HTTP error ${response.status}`);
62+
}
3763

38-
return data;
39-
} catch (err) {
40-
console.error(`API Error: ${err.message}`);
41-
throw err;
64+
return data;
65+
} catch (err) {
66+
lastErr = err;
67+
const cause = err?.cause?.code || err?.cause?.message || err?.cause || '';
68+
const retryable = RETRYABLE_CAUSES.has(err?.cause?.code) && attempt < MAX_ATTEMPTS;
69+
if (retryable) {
70+
console.error(`API Retry ${attempt}/${MAX_ATTEMPTS - 1} after ${cause}`);
71+
await sleep(1000 * attempt);
72+
continue;
73+
}
74+
console.error(`API Error: ${err.message}${cause ? ` (cause: ${cause})` : ''}`);
75+
throw err;
76+
}
4277
}
78+
throw lastErr;
4379
};
4480

4581
export { fetchApi };

src/utils/reviewApiHelper.js

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -265,33 +265,39 @@ class ReviewApiHelper extends CommonApiHelper {
265265
}
266266

267267
/**
268-
* Split a combined review request into per-file payloads.
269-
* Each payload has a single file's diff and content.
268+
* Split a combined review request into batched payloads.
269+
* Files are grouped in chunks of up to FILES_PER_BATCH so each backend
270+
* session reviews multiple files at once (matches pragent's batching).
270271
*/
271-
static splitIntoPerFileRequests(requestBody) {
272+
static splitIntoPerFileRequests(requestBody, batchSize = 5) {
272273
if (!requestBody?.diff_content?.length) return [];
273274

274275
const fileSections = requestBody.diff_content.split(/(?=^diff --git )/m).filter(Boolean);
275-
const perFileRequests = [];
276+
const perFile = [];
276277

277278
for (const section of fileSections) {
278279
const nameMatch = section.match(/^diff --git a\/.+ b\/(.+)$/m);
279280
if (!nameMatch) continue;
280-
const filename = nameMatch[1];
281+
perFile.push({ section, filename: nameMatch[1] });
282+
}
281283

284+
const batches = [];
285+
for (let i = 0; i < perFile.length; i += batchSize) {
286+
const slice = perFile.slice(i, i + batchSize);
282287
const fileContents = {};
283-
if (requestBody.file_contents?.[filename]) {
284-
fileContents[filename] = requestBody.file_contents[filename];
288+
for (const { filename } of slice) {
289+
if (requestBody.file_contents?.[filename]) {
290+
fileContents[filename] = requestBody.file_contents[filename];
291+
}
285292
}
286-
287-
perFileRequests.push({
288-
diff_content: section,
293+
batches.push({
294+
diff_content: slice.map((f) => f.section).join(''),
289295
file_contents: fileContents,
290-
_filename: filename,
296+
_filenames: slice.map((f) => f.filename),
291297
});
292298
}
293299

294-
return perFileRequests;
300+
return batches;
295301
}
296302
}
297303

0 commit comments

Comments
 (0)