A basic agent works well when tools succeed, inputs are valid, and the task is straightforward. Real usage is messier. Tools time out. The model calls a tool with an argument it can't handle. A query returns no results. The agent loops without making progress.
This article covers how to design agents that handle these situations cleanly — patterns for planning, error recovery, structured output, observability, and preventing runaway behaviour.
What happens when a tool fails
When a tool throws an exception in Spring AI, the agent loop breaks. The model never gets a chance to handle the failure — it just sees an error response or nothing at all.
The better pattern: catch failures inside the tool and return them as readable strings.
@Tool(description = "Get the current weather for a city in India.")
public String getWeather(@ToolParam(description = "City name") String city) {
try {
return weatherApi.fetch(city);
} catch (CityNotFoundException e) {
return "Weather data not available for '" + city + "'. " +
"This city may not be supported. Try a nearby major city.";
} catch (ApiTimeoutException e) {
return "Weather service is temporarily unavailable. " +
"Please try again or skip weather for this trip.";
}
}
When the model receives a failure message like this, it can respond intelligently: suggest an alternative city, skip that part of the answer, or tell the user the service is down. If the tool throws an uncaught exception, the model gets no information and the agent fails silently.
Rule: tools should never throw. They should return.
Returning partial results
When a task requires multiple tool calls and one fails, the agent doesn't have to fail entirely. Return what you have.
@Tool(description = "Get the cheapest ticket price between two cities.")
public String getTicketPrice(
@ToolParam(description = "Departure city") String from,
@ToolParam(description = "Destination city") String to) {
try {
String price = ticketApi.cheapest(from, to);
return "Cheapest ticket from " + from + " to " + to + ": " + price;
} catch (NoRouteException e) {
return "No direct bus or train route found between " + from + " and " + to + ". " +
"Consider checking flights or breaking the journey.";
}
}
The model receives "no direct route found" and continues building the rest of the trip plan — weather, attractions, hotels — without the ticket price. A partial, honest answer is far better than a broken agent.
Validating arguments inside tools
The model constructs tool arguments from natural language. It usually gets them right, but edge cases slip through — wrong formats, unsupported values, empty strings.
Validate at the start of every tool:
@Tool(description = "Get flight prices for a date. Date must be in YYYY-MM-DD format.")
public String getFlightPrice(
@ToolParam(description = "Departure city") String from,
@ToolParam(description = "Destination city") String to,
@ToolParam(description = "Travel date in YYYY-MM-DD format, e.g. 2026-11-15") String date) {
if (from == null || from.isBlank()) {
return "Error: departure city is required.";
}
if (!date.matches("\\d{4}-\\d{2}-\\d{2}")) {
return "Error: date must be in YYYY-MM-DD format (e.g. 2026-11-15). Received: " + date;
}
return flightApi.search(from, to, date);
}
Returning the invalid input in the error message is deliberate — the model sees what it passed, recognises the format mismatch, and either corrects it on a retry or tells the user what's missing.
Planning before acting
For complex tasks — multi-city trips, research tasks, anything with many steps — prompting the model to plan before calling tools improves output quality significantly.
ChatClient.builder(chatModel)
.defaultSystemPrompt("""
You are a travel planning assistant.
When given a complex request, first outline the steps you will take,
then call the tools in that order.
Do not call multiple tools simultaneously for a multi-step plan —
work through the steps in sequence so each result can inform the next.
If a tool returns an error or no data, note it in your answer
and continue with the remaining steps.
""")
.build();
"First outline the steps, then call tools in that order" encourages the model to reason about the full task before starting. Without this, the model sometimes starts calling tools immediately, misses dependencies between steps, and produces a less coherent result.
Guarding against infinite loops
An agent that keeps calling tools without producing a final answer is a runaway agent. This can happen when:
- Tools keep returning errors and the model keeps retrying
- The model interprets each tool result as requiring another tool call
- A bug in tool logic returns something that always looks like "incomplete"
Spring AI's ChatClient doesn't impose a hard limit on tool call iterations by default. You can wrap the agent call with a timeout:
@PostMapping("/chat")
public ResponseEntity<String> chat(@RequestBody ChatRequest request) {
try {
String response = CompletableFuture
.supplyAsync(() -> runAgent(request))
.get(30, TimeUnit.SECONDS);
return ResponseEntity.ok(response);
} catch (TimeoutException e) {
return ResponseEntity.status(504)
.body("The agent took too long to respond. Please try a simpler question.");
}
}
You can also add a counter inside a custom advisor or tool execution wrapper that aborts after N tool calls. Thirty seconds and ten tool calls per request are reasonable starting limits.
Sequential vs parallel tool execution
The default behaviour: the model calls tools in whatever order and parallelism it determines. For most tasks, this is correct. But some tasks have dependencies — the result of one tool call should inform the next.
Parallel is correct when tools are independent:
"What's the weather in Mysore, and what are the top attractions?"
Weather and attractions don't depend on each other. The model will correctly issue both calls simultaneously.
Sequential is correct when steps have dependencies:
"Find the cheapest travel option from Bangalore to my destination, then find hotels near the bus station."
The hotel search depends on knowing which bus station — which depends on the travel result. The model needs the first result before making the second call.
For dependency-heavy tasks, make this explicit in the system prompt: "Work through steps one at a time — each step may depend on the previous result."
Structured output
By default, the agent returns its final answer as a plain string. That works fine for chat — but when the response needs to feed into another system, be stored in a database, or be rendered as a structured UI component, plain text breaks down fast.
Spring AI's BeanOutputConverter lets you tell the model to return JSON matching a specific Java record. The converter injects formatting instructions into the system prompt automatically and parses the response back into a typed object.
Define the output shape:
public record TripPlan(
String destination,
String weather,
List<String> attractions,
String cheapestTransport,
int estimatedCostInr
) {}
Use the converter in your agent call:
@PostMapping("/plan")
public TripPlan plan(@RequestBody ChatRequest request) {
BeanOutputConverter<TripPlan> converter =
new BeanOutputConverter<>(TripPlan.class);
String response = chatClient
.prompt()
.user(request.message() + "\n\n" + converter.getFormat())
.tools(travelTools)
.call()
.content();
return converter.convert(response);
}
converter.getFormat() appends a JSON schema instruction to the user message — something like "Respond with a JSON object matching this schema: {...}". The model fills in the values; converter.convert() parses the JSON into your TripPlan record.
The output is now typed Java — you can store it, return it as an API response, or pass it downstream.
One thing to watch: if the model returns slightly malformed JSON (which happens, especially with complex schemas), convert() will throw. Wrap it in a try-catch and fall back to the raw string response when parsing fails. Keep output schemas flat and simple — deeply nested structures increase the chance of malformed output.
Observability — knowing what the agent actually did
In production, a user reports a wrong answer. Your first question is: which tools fired, with what arguments, and what did they return?
Without logging at the tool level, that question has no answer. You're left guessing.
The simplest approach: log at the start and end of every tool method.
@Tool(description = "Get the current weather for a city in India.")
public String getWeather(@ToolParam(description = "City name") String city) {
log.info("[tool:getWeather] called with city='{}'", city);
String result = weatherApi.fetch(city);
log.info("[tool:getWeather] returned: {}", result);
return result;
}
This gives you a complete trace of every agent execution in your logs — which tools ran, what the model passed, and what they returned. When an answer is wrong, you trace the conversation ID to the logs and see exactly what the agent did.
For a cleaner approach, Spring AI's Advisor interface lets you intercept every tool call centrally without touching each tool method:
@Component
public class ToolLoggingAdvisor implements RequestResponseAdvisor {
private static final Logger log = LoggerFactory.getLogger(ToolLoggingAdvisor.class);
@Override
public AdvisedRequest adviseRequest(AdvisedRequest request, Map<String, Object> context) {
log.info("[agent] incoming message: {}", request.userText());
return request;
}
@Override
public ChatResponse adviseResponse(ChatResponse response, Map<String, Object> context) {
log.info("[agent] final response: {}",
response.getResult().getOutput().getContent());
return response;
}
}
Register it alongside your other advisors:
ChatClient.builder(chatModel)
.defaultAdvisors(
new ToolLoggingAdvisor(),
new MessageChatMemoryAdvisor(new InMemoryChatMemory()),
new QuestionAnswerAdvisor(vectorStore, SearchRequest.defaults())
)
.build();
The advisor logs every request before it reaches the model and every response before it's returned to the caller. Combined with per-tool logging, you have full visibility into what the agent did on every request — without modifying controller or service code.
What to log at minimum:
- Conversation ID on every request
- Each tool name and the arguments the model passed
- Each tool's return value (or error)
- Total time taken for the agent call
Streaming agent responses
For long-running agent calls — multiple tool calls, slow APIs — streaming lets users see progress rather than waiting for a blank screen.
Spring AI supports streaming with tool use:
@PostMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> streamChat(@RequestBody ChatRequest request) {
return chatClient
.prompt()
.user(request.message())
.tools(travelTools)
.advisors(a -> a.param(
AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY,
request.conversationId()
))
.stream()
.content();
}
.stream().content() returns a Flux<String> that emits tokens as the model generates them. Tool calls still happen synchronously inside the loop — the model pauses streaming while a tool executes, then resumes streaming the result.
The user experience: they see text appearing token by token, with brief pauses when tools are called. This is significantly better than staring at a loading indicator for five seconds.
A checklist before shipping an agent
Before putting an agent in front of real users, run through these:
Tools all return strings, never throw. Exceptions break the loop invisibly.
Every tool has a "use this when..." clause. Without it, the model skips tools or uses the wrong one.
You've tested the "nothing found" case. Ask something the tools can't answer. The agent should say so honestly, not hallucinate.
You've set a timeout. An unbounded agent call is a denial-of-service risk.
You've tested with bad inputs. What happens when the user sends "???" or a very long message? The agent should degrade gracefully.
The system prompt includes a failure instruction. "If a tool returns an error, note it and continue" prevents the model from getting stuck retrying a broken tool.
Structured output has a fallback. If BeanOutputConverter.convert() throws on malformed JSON, catch it and return the raw string rather than crashing.
Tool calls are logged. Conversation ID, tool name, arguments, and return value — without this, debugging a wrong answer in production is guesswork.
What's next
The articles so far have covered building agents locally. The next phase is getting them in front of users — deploying to production, managing API keys, handling costs, and monitoring what the agent actually does in the wild.
Sham Prakash K — Backend Engineer, 4+ years in Java, Spring Boot, and distributed systems. Building AI backend infrastructure. Writing about what I actually learned, mistakes included.
Top comments (0)