agents
🧑🤝🧑 Specialization and diversity
In the world of autonomous development tools, a bot is only as good as its specialists. In Beto-Bot, we don't just have one giant brain doing everything, we have a team of specialized agents.
Note: Throughout the series I have made use of Google's Gemini model, but you should be able to easily swap it out with another model by changing the configuration in application.yml and grabbing another dependency. You can find all available models and their configuration in the Spring AI documentation.
Each agent is designed with a specific scope or responsibility. We isolate their responsibilities by implementing abstraction, ensuring that the system is modular, meaning we could add more specific agents later on without breaking the existing functionality. And ofcourse it keeps everything nice and seperated and we like clean, readable code.
🧮The Specialist Lineup
Because we fetch the issues and todos from github, we don't need an agent to do that for us. It also enables us to feed that information into our agents, so they know what they're working with. The added bonus is that it saves us a bit on input tokens as well.
Currently, Beto-Bot relies on two core agents to keep the gears turning:
The analyst: This agent is dedicated to writing some damn good analysis. The coder: This agent is dedicated to writing some... you guessed it... damn good code.
By using specialized agents, we can give each one a tailored system prompt. But more on those later.
🤖Agentic Config && Abstraction
Because our Specialists are designed with a specific scope of concern, we can create a generic Agent class that can be used by all of our agents.
Here we see a start method, used by all agents to start the conversation with the LLM. And a buildPrompt method, which is used to build the prompt for the LLM.
What happens in the start method: We're getting our bean (ChatClient), were feeding it a prompt, and we're asking it to execute the task and confirm when finished. And ofcourse we're logging the response.
public abstract class Agent {
private final Logger logger = LoggerFactory.getLogger(Agent.class);
private final ChatClient client;
public Agent(ChatClient client){
this.client = client;
}
public void start(GithubTask task) {
String finalResponse = client.prompt()
.system(promptSpec -> promptSpec.text(buildPrompt(task)))
.user("execute the task and confirm when finished")
.call()
.content();
logger.info("---Answer: {}", finalResponse);
}
abstract String buildPrompt(GithubTask task);
}Our bean for the analyst coder has quite some stuff going on at first glance but its actually pretty straight forward.
- We're injecting a builder for the ChatClient.
- We're injecting the model name from environment variable AGENT_MODEL_ANALYST.
- We're injecting the tool callback provider ( only needed for Gemini for now )
- We're injecting the github project service, which is used to present our custom tools
ChatClient is a Spring AI class that is used to build a chat client to interact with LLMs. I chose to be able to inject different models so that we can easily swap them out and use them according to their capabilities. For example, im using Gemini 3.1 Pro for the analyst and Gemini 3 Flash for the coder.
The toolCallbackProvider is used to provide the LLM with tools and in our case with Gemini, we used a Decorator Pattern on it to negate an issue with the GitHub server.
The projectService is basically a service that has some tools that the LLM can use to interact with. For example, to move issues after they've been completed. I've also added an advisor to the ChatClient bean. This is a simple logger that will log the conversation between the agent and the LLM. Think of advisors as custom tools you can use to verify or parse certain things in the conversation.
You'll see there's a defaultTools and a defaultToolCallbacks.
defaultTools are the custom tools that we expose to the LLM. defaultToolCallbacks are the callbacks that have been registered by the ToolCallbackProvider.
@Bean
public ChatClient analystChatClient(ChatClient.Builder builder,
@Value("${AGENT_MODEL_ANALYST}") String model,
@Qualifier("sanityCheckToolCallbackProvider") ToolCallbackProvider mcpToolProvider,
GithubProjectService githubProjectService) {
return builder
.defaultOptions(ChatOptions.builder()
.model(model)
.build())
.defaultAdvisors(
SimpleLoggerAdvisor.builder().build())
.defaultTools(githubProjectService)
.defaultToolCallbacks(mcpToolProvider)
.build();
}The custom ToolCallbackProvider :
Note: This is a workaround for an issue with the GitHub mcp server and Gemini. It's not a permanent solution. If using another provider, you might not need this. I noticed that the GitHub server was returning a simple string error instead of json and the model didn't know how to handle it. So I wrapped it in a json object.
// using this bean because there is an issue with mcp returning a simple string error instead of json
@Bean
@Primary
public ToolCallbackProvider sanityCheckToolCallbackProvider(ToolCallbackProvider mcpToolProvider) {
ToolCallback[] originalCallbacks = mcpToolProvider.getToolCallbacks();
List<ToolCallback> wrappedCallbacks = Arrays.stream(originalCallbacks)
.map(delegate -> (ToolCallback) new ToolCallback() {
@Override
public @NonNull ToolDefinition getToolDefinition() { return delegate.getToolDefinition(); }
@Override
public @NonNull String call(@NonNull String arguments) {
try {
String result = delegate.call(arguments);
// if result is empty or not JSON, wrap it in a JSON object
if (!result.trim().startsWith("{") && !result.trim().startsWith("[")) {
return "{\"result\": \"" + result.replace("\"", "\\\"") + "\"}";
}
return result;
} catch (Exception e) {
// feedback tool-level crashes and return as JSON so the model can handle it
return "{\"error\": \"" + e.getMessage().replace("\"", "\\\"") + "\"}";
}
}
})
.toList();
return new StaticToolCallbackProvider(wrappedCallbacks);
}The Analyst
A straightforward implementation of the Agent class:
@Component
public class AnalystAgent extends Agent{
public AnalystAgent(ChatClient analystChatClient) {
super(analystChatClient);
}
@Override
String buildPrompt(GithubTask task) {
return String.format("""
System context:
Owner: %s
Repo: %s
Always provide owner and repo when calling tools.
You are a functional analyst. Your job is to analyse a GitHub issue and prepare it for a coder.
Issue number: %d
Title: %s
Description: %s
Todo:
1. Use 'get_repository_tree' to know what paths and files are present.
2. Use 'get_file_contents' to read the relevant source files
3. Read any files directly relevant to the issue
4. Write a concise, in-depth analysis into the issue body using 'update_issue' with issue_number=%d, appending your analysis below the original description
5. Call 'moveTaskToAnalysed' with itemId='%s' to move the issue to the Analysed column
6. Reply with a short summary of what you analysed and what you recommended
Important: always complete all 6 steps before replying.
""",task.repositoryOwner(),
task.repository(),
task.number(),
task.title(),
task.body(),
task.number(),
task.itemId());
}
}By structuring the prompt, feeding it the task with targeted information, we can make sure the agent knows exactly what it needs to do. So far this has proven to be quite effective.
The Coder
@Component
public class CodingAgent extends Agent {
public CodingAgent(ChatClient coderChatClient) {
super(coderChatClient);
}
@Override
String buildPrompt(GithubTask task) {
return String.format("""
System context:
Owner: %s
Repo: %s
you must always provide these owner and repo values when calling tools
NOTE: this issue has been pre-analysed, the analysis is in the description.
Task:
You are senior Java Developer. You need to fix or implement the following issue:
Title: %s
Description: %s
Todo:
1. Identify the files mentioned in the analysis using 'get_repository_tree'
2. Use 'get_file_contents' for those specific files to get the current code.
3. Implement the fix or functionality on a new branch named 'feature/issue-%d' for %s.
4. Use 'push_files' to commit your changes.
5. Create a new pull request on %s
6. Call 'moveTaskToInProgress' with itemId='%s' to move the issue to the In progress column
7. Finish by replying you've finished the task.
""",task.repositoryOwner(),
task.repository(),
task.title(),
task.body(),
task.number(),
task.repository(),
task.repository(),
task.itemId());
}Other agents
Like i mentioned, there's still room to perhaps create a reviewer, a tester, etc. The beauty of this design is that you can easily add new agents to the system and assign them to work on items from a specific column. Or add a specific tag to an issue, like "urgent" or "bug" and have a specific agent that handles those. The possibilities are endless.
What’s Next?
And with that, you now know the crew that is working behind the scenes of Beto-Bot! In the next post we'll look at the ProjectService and some other utilities that keep everything running smoothly.
