Skip to content

Commit f643f86

Browse files
Gavin Williamsclaude
andcommitted
feat(web): multi-phase review agent with per-file parallel LLM calls
Replace the per-chunk (N LLM calls) architecture with a two-phase approach: 1. A single MR summary pass over all changed files to detect cross-file semantic changes (renames, signature changes, removed exports, etc.) that individual file reviewers should be aware of. 2. Per-file LLM reviews that batch all hunks for a file into a single call, parallelised across files via a concurrency-capped pool (MAX_CONCURRENT_FILE_REVIEWS = 5). This reduces LLM calls from one-per-hunk to one-per-file (plus one summary call), while giving each file review the full picture via the MR summary context. Additional changes: - Export `validateLogPath` from `invokeDiffReviewLlm` for reuse in the summary node - Add "How it works" section to the review agent docs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 29dfe17 commit f643f86

6 files changed

Lines changed: 213 additions & 49 deletions

File tree

docs/docs/features/agents/review-agent.mdx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,24 @@ title: AI Code Review Agent
33
sidebarTitle: AI code review agent
44
---
55

6-
This agent provides codebase-aware reviews for your GitHub PRs and GitLab MRs. For each diff, the agent fetches relevant context from your indexed codebase and feeds it into a configured language model for a detailed review.
6+
This agent provides codebase-aware reviews for your GitHub PRs and GitLab MRs. When a review is triggered, the agent runs a two-phase LLM pipeline and posts inline comments on the changed files.
77

88
The AI Code Review Agent is [fair source](https://github.com/sourcebot-dev/sourcebot/tree/main/packages/web/src/features/agents/review-agent) and packaged in [Sourcebot](https://github.com/sourcebot-dev/sourcebot). To get started, [deploy Sourcebot](/docs/deployment/docker-compose) and follow the configuration instructions below.
99

1010
![AI Code Review Agent Example](/images/review_agent_example.png)
1111

12+
# How it works
13+
14+
When a review is triggered, the agent runs the following steps:
15+
16+
1. **MR summary pass.** A single LLM call analyses the full set of changed files to identify cross-file semantic changes, such as renamed functions, changed signatures, removed exports, or behaviour changes with cross-file implications. This summary is passed as additional context into each per-file review.
17+
18+
2. **Per-file reviews.** One LLM call is made per changed file. Each call receives the file's complete diff (all hunks combined), the full file content, the PR title and description, the MR summary from step 1, and any configured context files. Reviews run in parallel, up to five files at a time.
19+
20+
3. **Inline comments.** The agent posts the results as inline review comments on the PR or MR.
21+
22+
If the MR summary pass finds no cross-file concerns, it returns nothing and the per-file reviews proceed without it.
23+
1224
# Language model
1325

1426
The review agent uses whichever language model you have configured in your `config.json`. All providers supported by Sourcebot (OpenAI, Anthropic, AWS Bedrock, Azure OpenAI, and others) work out of the box.
@@ -135,7 +147,7 @@ If you have multiple models configured, set `REVIEW_AGENT_MODEL` to the `display
135147

136148
By default, the agent does not review PRs and MRs automatically. To enable automatic reviews on every new or updated PR/MR, set `REVIEW_AGENT_AUTO_REVIEW_ENABLED` to `true`.
137149

138-
You can also trigger a review manually by commenting `/review` on any PR or MR. To use a different command, set `REVIEW_AGENT_REVIEW_COMMAND` to your preferred value (without the leading slash).
150+
You can also trigger a review manually by commenting `review` on any PR or MR. To use a different command, set `REVIEW_AGENT_REVIEW_COMMAND` to your preferred value.
139151

140152
# Environment variable reference
141153

packages/web/src/features/agents/review-agent/nodes/generateDiffReviewPrompt.ts

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,29 +4,35 @@ import { createLogger } from "@sourcebot/shared";
44

55
const logger = createLogger('generate-diff-review-prompt');
66

7-
export const generateDiffReviewPrompt = async (diff: sourcebot_diff, context: sourcebot_context[], rules: string[]) => {
7+
export const generateDiffReviewPrompt = async (diffs: sourcebot_diff[], context: sourcebot_context[], rules: string[]) => {
88
logger.debug("Executing generate_diff_review_prompt");
9-
10-
const prompt = `
11-
You are an expert software engineer that excels at reviewing code changes. Given the input, additional context, and rules defined below, review the code changes and provide a detailed review. The review you provide
12-
must conform to all of the rules defined below. The output format of your review must conform to the output format defined below.
13-
14-
# Input
159

16-
The input is the old and new code snippets, which represent a single hunk from a git diff. The old code snippet is the code before the changes were made, and the new code snippet is the code after the changes were made. Each code snippet
17-
is a sequence of lines each with a line number.
10+
const hunksText = diffs.map((diff, i) => `
11+
## Hunk ${i + 1}
1812
19-
## Old Code Snippet
13+
### Old Code
2014
2115
\`\`\`
2216
${diff.oldSnippet}
2317
\`\`\`
2418
25-
## New Code Snippet
19+
### New Code
2620
2721
\`\`\`
2822
${diff.newSnippet}
2923
\`\`\`
24+
`).join('\n');
25+
26+
const prompt = `
27+
You are an expert software engineer that excels at reviewing code changes. Given the input, additional context, and rules defined below, review the code changes and provide a detailed review. The review you provide
28+
must conform to all of the rules defined below. The output format of your review must conform to the output format defined below.
29+
30+
# Input
31+
32+
The input is the old and new code snippets for one or more hunks from a git diff for a single file. The old code snippet is the code before the changes were made, and the new code snippet is the code after the changes were made. Each code snippet
33+
is a sequence of lines each with a line number.
34+
35+
${hunksText}
3036
3137
# Additional Context
3238
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { sourcebot_context, sourcebot_pr_payload } from "@/features/agents/review-agent/types";
2+
import { getAISDKLanguageModelAndOptions, getConfiguredLanguageModels } from "@/features/chat/utils.server";
3+
import { validateLogPath } from "@/features/agents/review-agent/nodes/invokeDiffReviewLlm";
4+
import { env } from "@sourcebot/shared";
5+
import { generateText } from "ai";
6+
import { createLogger } from "@sourcebot/shared";
7+
import fs from "fs";
8+
9+
const logger = createLogger('generate-mr-summary');
10+
11+
/**
12+
* Makes a single LLM call over the entire MR diff to identify cross-file
13+
* semantic changes (renames, signature changes, removed exports, etc.) that
14+
* individual per-file reviewers should be aware of. Returns null when there
15+
* are no notable cross-file concerns or if the call fails — the per-file
16+
* review pipeline always continues regardless.
17+
*/
18+
export const generateMrSummary = async (
19+
pr_payload: sourcebot_pr_payload,
20+
reviewAgentLogPath: string | undefined,
21+
): Promise<sourcebot_context | null> => {
22+
logger.debug("Executing generate_mr_summary");
23+
24+
const models = await getConfiguredLanguageModels();
25+
if (models.length === 0) {
26+
logger.warn("No language models configured, skipping MR summary");
27+
return null;
28+
}
29+
30+
let selectedModel = models[0];
31+
if (env.REVIEW_AGENT_MODEL) {
32+
const match = models.find((m) => m.displayName === modelName);
33+
if (match) {
34+
selectedModel = match;
35+
} else {
36+
logger.warn(`REVIEW_AGENT_MODEL="${env.REVIEW_AGENT_MODEL}" did not match any configured model displayName. Falling back to the first configured model.`);
37+
}
38+
}
39+
40+
const { model, providerOptions, temperature } = await getAISDKLanguageModelAndOptions(selectedModel);
41+
42+
const diffSummary = pr_payload.file_diffs.map((fileDiff) => {
43+
const header = fileDiff.from !== fileDiff.to
44+
? `File: ${fileDiff.to} (renamed from ${fileDiff.from})`
45+
: `File: ${fileDiff.to}`;
46+
const hunks = fileDiff.diffs.map((d, i) =>
47+
`Hunk ${i + 1}:\n--- Old\n${d.oldSnippet}\n+++ New\n${d.newSnippet}`
48+
).join('\n\n');
49+
return `${header}\n${hunks}`;
50+
}).join('\n\n---\n\n');
51+
52+
const prompt = `You are reviewing a pull request titled "${pr_payload.title}".
53+
54+
Below are all the changed files and their diffs. Identify and summarise semantic changes that reviewers of individual files should be aware of — such as renamed functions or types, changed signatures or interfaces, removed exports, or behaviour changes with cross-file implications.
55+
56+
If there are no noteworthy cross-file semantic concerns, respond with an empty string.
57+
58+
# Changed Files
59+
60+
${diffSummary}`;
61+
62+
if (reviewAgentLogPath) {
63+
validateLogPath(reviewAgentLogPath);
64+
fs.appendFileSync(reviewAgentLogPath, `\n\nMR Summary Prompt:\n${prompt}`);
65+
}
66+
67+
try {
68+
const result = await generateText({
69+
model,
70+
system: "You are a code review assistant. Provide a concise plain-text summary of cross-file semantic changes in a pull request. Respond with an empty string if there are none.",
71+
prompt,
72+
providerOptions,
73+
temperature,
74+
});
75+
76+
const summary = result.text.trim();
77+
78+
if (reviewAgentLogPath) {
79+
validateLogPath(reviewAgentLogPath);
80+
fs.appendFileSync(reviewAgentLogPath, `\n\nMR Summary Response:\n${summary}`);
81+
}
82+
if (!summary) {
83+
logger.debug("No cross-file semantic changes detected, skipping summary context");
84+
return null;
85+
}
86+
87+
logger.debug("Completed generate_mr_summary");
88+
return {
89+
type: "pr_summary",
90+
description: "A summary of cross-file semantic changes in this pull request",
91+
context: summary,
92+
};
93+
} catch (error) {
94+
logger.error("Error generating MR summary, proceeding without it:", error);
95+
return null;
96+
}
97+
};
Lines changed: 83 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,101 @@
1-
import { sourcebot_pr_payload, sourcebot_diff_review, sourcebot_file_diff_review, sourcebot_context } from "@/features/agents/review-agent/types";
1+
import { sourcebot_pr_payload, sourcebot_file_diff_review, sourcebot_context } from "@/features/agents/review-agent/types";
22
import { generateDiffReviewPrompt } from "@/features/agents/review-agent/nodes/generateDiffReviewPrompt";
33
import { invokeDiffReviewLlm } from "@/features/agents/review-agent/nodes/invokeDiffReviewLlm";
44
import { fetchFileContent } from "@/features/agents/review-agent/nodes/fetchFileContent";
5+
import { generateMrSummary } from "@/features/agents/review-agent/nodes/generateMrSummary";
56
import { createLogger } from "@sourcebot/shared";
67

78
const logger = createLogger('generate-pr-review');
89

9-
export const generatePrReviews = async (reviewAgentLogFileName: string | undefined, pr_payload: sourcebot_pr_payload, rules: string[]): Promise<sourcebot_file_diff_review[]> => {
10-
logger.debug("Executing generate_pr_reviews");
10+
const MAX_CONCURRENT_FILE_REVIEWS = 5;
1111

12-
const file_diff_reviews: sourcebot_file_diff_review[] = [];
13-
for (const file_diff of pr_payload.file_diffs) {
14-
const reviews: sourcebot_diff_review[] = [];
12+
/**
13+
* Runs tasks with a bounded concurrency limit, returning results in the same
14+
* order as the input array and using the same PromiseSettledResult shape as
15+
* Promise.allSettled.
16+
*/
17+
async function withConcurrencyLimit<T>(
18+
tasks: Array<() => Promise<T>>,
19+
limit: number,
20+
): Promise<PromiseSettledResult<T>[]> {
21+
const results: PromiseSettledResult<T>[] = new Array(tasks.length);
22+
let nextIndex = 0;
1523

16-
for (const diff of file_diff.diffs) {
24+
async function worker() {
25+
while (nextIndex < tasks.length) {
26+
const index = nextIndex++;
1727
try {
18-
const fileContentContext = await fetchFileContent(pr_payload, file_diff.to);
19-
const context: sourcebot_context[] = [
20-
{
21-
type: "pr_title",
22-
description: "The title of the pull request",
23-
context: pr_payload.title,
24-
},
25-
{
26-
type: "pr_description",
27-
description: "The description of the pull request",
28-
context: pr_payload.description,
29-
},
30-
fileContentContext,
31-
];
32-
33-
const prompt = await generateDiffReviewPrompt(diff, context, rules);
34-
35-
const diffReview = await invokeDiffReviewLlm(reviewAgentLogFileName, prompt);
36-
reviews.push(...diffReview.reviews);
37-
} catch (error) {
38-
logger.error(`Error generating review for ${file_diff.to}: ${error}`);
28+
results[index] = { status: 'fulfilled', value: await tasks[index]() };
29+
} catch (reason) {
30+
results[index] = { status: 'rejected', reason };
3931
}
4032
}
41-
42-
if (reviews.length > 0) {
43-
file_diff_reviews.push({
33+
}
34+
35+
await Promise.all(Array.from({ length: Math.min(limit, tasks.length) }, worker));
36+
return results;
37+
}
38+
39+
export const generatePrReviews = async (reviewAgentLogFileName: string | undefined, pr_payload: sourcebot_pr_payload, rules: string[]): Promise<sourcebot_file_diff_review[]> => {
40+
logger.debug("Executing generate_pr_reviews");
41+
42+
// Run MR summary upfront to detect cross-file semantic changes.
43+
const mrSummaryResult = await Promise.allSettled([
44+
generateMrSummary(pr_payload, reviewAgentLogFileName),
45+
]);
46+
47+
const mrSummaryContext: sourcebot_context[] = [];
48+
if (mrSummaryResult[0].status === 'fulfilled' && mrSummaryResult[0].value !== null) {
49+
mrSummaryContext.push(mrSummaryResult[0].value);
50+
} else if (mrSummaryResult[0].status === 'rejected') {
51+
logger.warn(`MR summary generation failed: ${mrSummaryResult[0].reason}`);
52+
}
53+
54+
// Per-file review — one LLM call per file, parallelised with a concurrency cap.
55+
logger.debug(`Reviewing ${pr_payload.file_diffs.length} file(s)`);
56+
const fileResults = await withConcurrencyLimit(
57+
pr_payload.file_diffs.map((file_diff) => async () => {
58+
const fileContentContext = await fetchFileContent(pr_payload, file_diff.to);
59+
const context: sourcebot_context[] = [
60+
{
61+
type: "pr_title",
62+
description: "The title of the pull request",
63+
context: pr_payload.title,
64+
},
65+
{
66+
type: "pr_description",
67+
description: "The description of the pull request",
68+
context: pr_payload.description,
69+
},
70+
fileContentContext,
71+
...mrSummaryContext,
72+
];
73+
74+
const prompt = await generateDiffReviewPrompt(file_diff.diffs, context, rules);
75+
const diffReview = await invokeDiffReviewLlm(reviewAgentLogFileName, prompt);
76+
77+
if (diffReview.reviews.length === 0) {
78+
return null;
79+
}
80+
81+
return {
4482
filename: file_diff.to,
45-
reviews: reviews,
46-
});
83+
oldFilename: file_diff.from,
84+
reviews: diffReview.reviews,
85+
} satisfies sourcebot_file_diff_review;
86+
}),
87+
MAX_CONCURRENT_FILE_REVIEWS,
88+
);
89+
90+
const file_diff_reviews: sourcebot_file_diff_review[] = [];
91+
for (const result of fileResults) {
92+
if (result.status === 'rejected') {
93+
logger.error(`Error generating review: ${result.reason}`);
94+
} else if (result.value !== null) {
95+
file_diff_reviews.push(result.value);
4796
}
4897
}
4998

5099
logger.debug("Completed generate_pr_reviews");
51100
return file_diff_reviews;
52-
}
101+
}

packages/web/src/features/agents/review-agent/nodes/gitlabMrParser.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ export const gitlabMrParser = async (
8888
(file): file is sourcebot_file_diff => file !== null,
8989
);
9090

91-
logger.debug("Completed gitlab_mr_parser");
91+
logger.debug(`Completed gitlab_mr_parser: ${filteredSourcebotFileDiffs.length} file(s) parsed`);
9292
return {
9393
title: mr.title,
9494
description: mr.description ?? "",

packages/web/src/features/agents/review-agent/nodes/invokeDiffReviewLlm.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export const getReviewAgentLogDir = (): string => {
1212
return path.join(env.DATA_CACHE_DIR, 'review-agent');
1313
};
1414

15-
const validateLogPath = (logPath: string): void => {
15+
export const validateLogPath = (logPath: string): void => {
1616
const resolved = path.resolve(logPath);
1717
const logDir = getReviewAgentLogDir();
1818
if (!resolved.startsWith(logDir + path.sep)) {

0 commit comments

Comments
 (0)