DEV Community

lbobylev
lbobylev

Posted on

Code Review agent with Spring AI

Repository

An AI code review agent is easy to build in one evening: take a diff, send it to an LLM, and ask it to return comments for a Pull Request.
But this kind of bot quickly hits an unpleasant reality. The model can confidently point to a line that does not exist in the diff. It can set REQUEST_CHANGES for a weak finding. It can call tools too many times. It can return JSON that looks valid but breaks the GitHub API.
The architectural principle is simple: the model helps find problems, but deterministic code decides what is safe to publish to GitHub.

What the agent does

The agent receives a repository and a Pull Request number. Then it:

  • prepares a local workspace;
  • gets the PR diff and changed file list through gh;
  • runs a Spring AI agent with limited repository-reading tools;
  • receives structured findings from the model;
  • validates the findings against the diff;
  • builds a GitHub review payload;
  • submits the review through gh api if there are valid inline comments.

Workflow diagram showing PR diff and workspace tools feeding into a Spring AI review agent, followed by review findings, a validation layer, GitHub review payload generation, and submission through the GitHub API.

Preparing the local workspace

Before the review starts, the application creates a fresh local workspace for the Pull Request. The workspace lives under the project directory. Each run starts from a clean state: the previous workspace is removed, a new one is created, the target repository is cloned there, and the requested Pull Request is checked out in that local clone. This local checkout is the only repository context available to the agent tools. When the model asks to read a file, search text, or find files by glob, those tools operate inside that checkout, not on the whole machine.

Getting PR context

Before the model is called, the application collects two pieces of PR context through the GitHub CLI: the full diff and the list of changed files.

String diff = commandRunner.run(List.of(
        "gh", "pr", "diff", Integer.toString(prNumber),
        "--repo", repo
));

String changedFiles = commandRunner.run(List.of(
        "gh", "pr", "diff", Integer.toString(prNumber),
        "--repo", repo,
        "--name-only"
));
Enter fullscreen mode Exit fullscreen mode

The diff is the primary review input. The changed file list is only used to describe the review scope and help the model understand what changed.

Both values are treated as untrusted text. They are passed into the prompt, but they do not decide what gets published. The final comments still have to pass the validation layer later.

The model returns findings

The first important decision: the model must not build the GitHub payload directly.
Instead, it returns an internal findings model:

public record ReviewFinding(
        String path,
        int line,
        String body,
        boolean blocking,
        FindingCategory category
) {}

public enum FindingCategory {
    bug,
    security,
    data_loss,
    performance,
    duplication,
    test_gap
}
Enter fullscreen mode Exit fullscreen mode

The model can say: "I found a problem in file X on line Y". But it does not make the final decision about:

  • whether a comment can be placed on that line;
  • whether the review should block the merge;
  • how many comments will be sent to GitHub;

This way the LLM stays a reviewer, but it is not responsible for building the final Pull Request review.

Manual tool loop

Spring AI 2 can execute tools automatically through ToolCallingAdvisor. For a regular chatbot, this is convenient: the model asks for a tool, the framework executes it, and the model continues.
For a review agent, this is not enough. It needs a hard limit on the number of tool calls per review. Otherwise, a bad prompt or a poor model decision can turn the review into an endless repository investigation.
Budget control is an important part of this: each tool call usually leads to another model call, and the tool result is added back into the context. The more files and search results the model requests, the more tokens are involved in the next review steps. Without a limit, the cost of one PR becomes hard to predict.
The MAX_TOOL_CALLS limit defines a clear boundary: the agent can check a few concrete hypotheses, but it cannot turn the review into an unbounded search for a solution.
For this, the agent uses user-controlled tool execution: ChatModel is called directly, and tool calls are executed through ToolCallingManager in an explicit loop. Since the application owns the loop, it also parses the final model answer explicitly. Instead of the high-level ChatClient.entity(...) flow, the agent uses Spring AI's BeanOutputConverter: it adds the expected response format to the prompt and converts the final model message into ReviewFindingsPayload.

The full class looks like this:

@Service
final class ReviewAgent {

    private static final int MAX_TOOL_CALLS = 3;
    private static final String MODEL_NAME = "gpt-4.1";

    private final ChatModel chatModel;
    private final ToolCallingManager toolCallingManager;

    ReviewAgent(ChatModel chatModel, ToolCallingManager toolCallingManager) {
        this.chatModel = chatModel;
        this.toolCallingManager = toolCallingManager;
    }

    private Builder getBuilder() {
        return OpenAiChatOptions.builder()
                .model(MODEL_NAME)
                .temperature(0.0)
                .maxTokens(12000);
    }

    ReviewFindingsPayload review(Path repoPath, String diff, String changedFiles) {
        BeanOutputConverter<ReviewFindingsPayload> outputConverter = new BeanOutputConverter<>(
                ReviewFindingsPayload.class);
        WorkspaceTools workspaceTools = new WorkspaceTools(repoPath);
        List<ToolCallback> toolCallbacks = Arrays.asList(MethodToolCallbackProvider.builder()
                .toolObjects(workspaceTools)
                .build()
                .getToolCallbacks());

        OpenAiChatOptions options = getBuilder().toolCallbacks(toolCallbacks).build();
        OpenAiChatOptions finalAnswerOptions = getBuilder().build();

        List<Message> messages = new ArrayList<>();
        messages.add(new SystemMessage(ReviewPrompts.SYSTEM_PROMPT));
        messages.add(
                new UserMessage(ReviewPrompts.userPrompt(diff, changedFiles) + "\n\n" + outputConverter.getFormat()));

        Prompt prompt = new Prompt(messages, options);
        ChatResponse response = chatModel.call(prompt);
        int usedToolCalls = 0;

        while (response.hasToolCalls()) {
            int requestedToolCalls = response.getResult().getOutput().getToolCalls().size();
            if (usedToolCalls + requestedToolCalls > MAX_TOOL_CALLS) {
                messages = new ArrayList<>(prompt.getInstructions());
                messages.add(new UserMessage(ReviewPrompts.toolLimitPrompt(MAX_TOOL_CALLS)));
                prompt = new Prompt(messages, finalAnswerOptions);
                response = chatModel.call(prompt);
                break;
            }

            usedToolCalls += requestedToolCalls;
            ToolExecutionResult result = toolCallingManager.executeToolCalls(prompt, response);
            prompt = new Prompt(result.conversationHistory(), options);
            response = chatModel.call(prompt);
        }

        String content = response.getResult().getOutput().getText();
        if (content == null || content.isBlank()) {
            throw new ReviewException("Error: agent returned no final response");
        }

        try {
            return outputConverter.convert(content);
        } catch (RuntimeException error) {
            throw new ReviewException("Error: structured response is malformed: " + error.getMessage(), error);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

If conversion fails, the review stops before anything is submitted. Unknown enum values fail during conversion, and successfully converted findings still go through the validation layer later. That is where empty values, duplicate comments, invalid diff coordinates, and zero or negative line numbers are filtered out.

This choice does not oppose ToolCallingAdvisor: recursive advisors remain a good Spring-native default for many scenarios. Here, the agent needs more direct control over the number of tool calls and the budget of one review, so the loop is moved into the application. Spring AI does not currently provide a simple built-in maxToolCalls, see spring-ai#3333; for more on recursive advisors, see the Spring article.

Tool limitations

The agent needs tools to check context outside the diff. For example, it may need to inspect an existing method, usage, config, or test.
But tools must not become arbitrary shell access. This agent has only three workspace-scoped tools:

@Tool(description = "Read a UTF-8 text file from the current workspace.")
String readFile(String path) { ... }

@Tool(description = "Search workspace text files for a literal string.")
String searchText(String query, String path) { ... }

@Tool(description = "Find workspace files matching a glob pattern.")
String glob(String pattern) { ... }
Enter fullscreen mode Exit fullscreen mode

They are intentionally limited:

  • paths must stay inside the workspace;
  • heavy directories like .git, node_modules, build, and target are skipped;
  • only source/config extensions are allowed;
  • file size is limited;
  • result count is limited.

With these safety boundaries, the model has enough context for review, but it cannot freely scan everything.

Validate comment coordinates against the diff

A GitHub inline review comment cannot be placed on any arbitrary file line. The comment must target a line that exists in the PR diff on the correct side. The model does not guarantee this. Even when it sees the diff, it can be off by one line or point to a deleted line. So the code first parses the diff and builds a set of allowed coordinates:

record LineRef(String path, int line) {}
Enter fullscreen mode Exit fullscreen mode

The validation layer has a simple but critical check:

if (!allowedLines.contains(new LineRef(path, finding.line()))) {
    continue;
}
Enter fullscreen mode Exit fullscreen mode

If (path, line) does not exist on the RIGHT side of the diff, the finding is dropped. This protects against two problems at once:

  • GitHub will not reject the whole review because of one bad inline comment;
  • the model cannot comment on arbitrary lines outside the diff.

The parser is intentionally narrow: it does not try to fully understand every possible Git diff feature. It only extracts the RIGHT-side coordinates that GitHub can accept for inline review comments. In practice, this still means handling several edge cases carefully: multiple hunks in one file, deleted files, \ No newline at end of file markers, renamed files, paths with spaces or quoting, binary files, and metadata-only changes such as file mode updates. Anything the parser cannot map to a valid RIGHT-side line should be treated as not commentable.

Code chooses REQUEST_CHANGES

The model can return blocking=true, but the final GitHub event is still computed deterministically.
The reason is simple: REQUEST_CHANGES is a strong side effect. It should not be delegated to the model without checks.
The code has a whitelist of categories that are allowed to block a merge:

private static final Set<FindingCategory> BLOCKING_CATEGORIES = Set.of(
        FindingCategory.bug,
        FindingCategory.security,
        FindingCategory.data_loss
);
Enter fullscreen mode Exit fullscreen mode

The final event is computed only from findings that have already passed validation:

var hasBlockingFinding = validFindings.stream()
        .anyMatch(finding -> finding.blocking()
                && BLOCKING_CATEGORIES.contains(finding.category()));

var event = hasBlockingFinding ? "REQUEST_CHANGES" : "COMMENT";
Enter fullscreen mode Exit fullscreen mode

Even if the model marks duplication or test_gap as blocking, that finding cannot block the PR.
This small rule changes the reliability of the agent a lot. The model can recommend, but the merge-blocking policy stays in code.

Filtering before submission

Before building the GitHub payload, findings go through a few more checks to make publication predictable:

  • path and body are trimmed;
  • empty values are dropped;
  • too long body text is shortened;
  • duplicates are removed;
  • at most 10 inline comments are sent.

After validation, the application builds the final GitHub review payload:

{
  "event": "COMMENT",
  "body": "AI-assisted review",
  "comments": [
    {
      "path": "src/main/java/App.java",
      "line": 42,
      "side": "RIGHT",
      "body": "This value may be null."
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

At this point the payload is no longer raw model output. It contains only comments that passed the application checks.

Conclusion

In practice, this is the trust boundary: the model analyzes the change and suggests findings, while the application enforces the budget, validates the output, and decides what is published.
That boundary matters because PR review is not a harmless demo flow. The result is posted into a real Pull Request, where a bad comment can waste reviewer time and a bad REQUEST_CHANGES can affect the merge decision.
Spring AI 2 gives a solid foundation for tool-using agents, but production workflows still need application-level control over limits, validation, and side effects.

Top comments (0)