Step 02 - Agent Guardrails and Compliance
A family of five asks Miles of Smiles for a road trip, but the vehicle agent recommends a two-seat sports car. Even with the skills we added in Step 01, the model-backed agents can still overlook our instructions when generating a response. That’s the joy of working with probablistic AI models. The application therefore needs safety and compliance checks of its own before passing recommendations to the rest of the planning pipeline.
We’ll attach output guardrails to the vehicle and itinerary agents so they can request another response or rewrite a recommendation. We’ll also give the cost estimator a tool that calculates rental prices from a small rate list, with a tool input guardrail to reject invalid arguments before the calculation runs. Fixed-response tests will let us inspect a corrected vehicle and distinguish exhausted recommendation checks from an unrelated planning failure, without having to provoke a live-model mistake.
Per-agent output guardrails
The input guardrails from Section 1 checked the customer’s message before it reached the model. Here on the other hand, we need to check the recommendations the agents produce before the cost estimator uses them. Output guardrails run after an agent has finished its model and tool interactions, allowing the application to inspect the response before accepting it.
We’ll add these checks to the parallel research phase from Step 01. An itinerary guardrail will look for an absent or empty itinerary and a short list of dangerous-area phrases, while a vehicle guardrail will compare the recommendation with the customer’s request.
A guardrail can accept the answer, fix it, or ask the model to try again. The four responses below determine which path the workflow takes.
flowchart LR
accTitle: Four ways an output guardrail can respond
accDescr: The guardrail accepts the original answer with success or a Java correction with successWith. Both continue the workflow. Retry requests another answer without new guidance, while reprompt adds instructions. Each new model answer is checked again, up to the configured attempt limit.
Model[Model response] --> Check{Guardrail checks}
Check --> Pass["Accept original answer<br/>success()"]
Check --> Fix["Accept Java correction<br/>successWith(AiMessage)"]
Check --> Retry["Try again without new guidance<br/>retry(errorMessage)"]
Check --> Reprompt["Try again with instructions<br/>reprompt(errorMessage, instructions)"]
Pass --> Continue[Continue workflow]
Fix --> Continue
Retry --> Model
Reprompt --> Model
classDef accepted fill:#e8f5e9,stroke:#2e7d32,color:#16351a
classDef regenerate fill:#fff3e0,stroke:#b56500,color:#593200
class Pass,Fix,Continue accepted
class Retry,Reprompt regenerate
For the family of five, successWith(AiMessage) lets the application replace the unsuitable vehicle category, description, and reasoning directly. The workflow continues with that corrected answer without another model call. If the original answer already passes, success() keeps it as it is.
When the model needs another attempt, retry(errorMessage) requests a new response without adding guidance. With reprompt(errorMessage, instructions), the first argument describes the problem for the application log, and the second tells the model what to change. Our vehicle guardrail uses this to request an affordable option when a recommendation breaks the economy-budget rule.
Both paths back to the model run the new response through the checks again. We’ll also set an attempt limit when registering the guardrails so that repeated failures stop planning instead of looping indefinitely.
Tool input guardrails
Once the vehicle and itinerary recommendations have passed their checks, the cost agent uses them to estimate the trip’s expenses. We’ll give this agent a rental calculator so it can calculate the vehicle cost using Miles of Smiles’ daily rates. Calling a tool introduces another place where the model can make a mistake, even when the recommendations it received have already been checked.
Checking tool arguments is particularly important when a tool can modify the file system or write to a database. Before a destructive action such as deleting files or removing database records, a tool input guardrail can check whether the requested paths or records are within the permitted scope. An output guardrail would only inspect the agent’s response after the action had already happened.
Our rental calculator has no such serious side effects, but the model could still request an unknown vehicle category or a rental of zero days. These values need checking before the calculator runs.
A tool input guardrail sits between the agent’s tool request and the calculation. Valid arguments let the tool execute. Invalid arguments block that call and return an error to the model as a tool result, giving it a chance to correct its request.
flowchart LR
Agent[Cost agent] -->|Category and days| Check{Valid arguments?}
Check -->|No| Error[Error returned to model]
Error --> Agent
Check -->|Yes| Tool[Calculate rental price]
Tool -->|Daily rate and subtotal| Agent
This check happens within the agent’s tool-calling conversation. Rejecting a tool call does not consume the output guardrail’s attempt allowance. We’ll add the calculator and its input guardrail after implementing the vehicle and itinerary output checks.
Preparing the working copy
Keep the model configuration from Step 01 and make sure OPENAI_API_KEY is set in the terminal used to run the application. The fixed-response tests later in this chapter do not call the model, but generating a trip through the browser still needs it.
Continue in your Step 01 working copy and apply the changes below. Use the completed Step 02 project for comparison if you get stuck.
The starter already displays the error messages returned by the application. Once we add the guardrails and exception mapper below, it will also show why a trip could not pass the recommendation checks.
The completed project already contains the changes below. You can read through the implementation without editing, then join the exercise at Inspecting guardrail execution.
Open section-3/step-02 and start dev mode:
Logging guardrail decisions
The guardrails need a shared place to record their decisions so we can inspect them while testing.
Create src/main/java/com/tripplanner/guardrails/GuardrailAuditLog.java:
package com.tripplanner.guardrails;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import org.jboss.logging.Logger;
import java.time.Instant;
import java.util.Deque;
import java.util.List;
import java.util.concurrent.ConcurrentLinkedDeque;
@ApplicationScoped
public class GuardrailAuditLog {
public record AuditEntry(Instant timestamp, String guardrail, String decision, String reason) {}
private static final int MAX_ENTRIES = 100;
private final Deque<AuditEntry> entries = new ConcurrentLinkedDeque<>();
@Inject
Logger logger;
public void log(String guardrail, String decision, String reason) {
AuditEntry entry = new AuditEntry(Instant.now(), guardrail, decision, reason);
entries.addLast(entry);
while (entries.size() > MAX_ENTRIES) {
entries.pollFirst();
}
logger.infof("🛡️ [%s] %s — %s", guardrail, decision, reason);
}
public List<AuditEntry> getRecentEntries() {
return List.copyOf(entries);
}
}
Calling log() writes the guardrail’s name, decision, and reason to the terminal. It also keeps the latest 100 entries in memory for tests to inspect through getRecentEntries(), until the application restarts.
For example, a guardrail could record a decision like this (shortened for readability):
Validating structured output with retry()
The itinerary guardrail asks the model to try again when its response has invalid JSON, an empty itinerary, or a phrase from our dangerous-area list.
Create src/main/java/com/tripplanner/guardrails/TripSafetyGuardrail.java:
package com.tripplanner.guardrails;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.langchain4j.data.message.AiMessage;
import dev.langchain4j.guardrail.OutputGuardrail;
import dev.langchain4j.guardrail.OutputGuardrailResult;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
@ApplicationScoped
public class TripSafetyGuardrail implements OutputGuardrail {
private static final Set<String> DANGEROUS_KEYWORDS = Set.of(
"war zone", "conflict area", "active military", "travel ban",
"do not travel", "armed conflict", "combat zone", "no-go zone");
@Inject
GuardrailAuditLog auditLog;
@Inject
ObjectMapper objectMapper;
@Override
public OutputGuardrailResult validate(AiMessage responseFromLLM) {
String text = responseFromLLM.text();
if (text == null || text.isBlank()) {
auditLog.log("TripSafetyGuardrail", "SKIP", "No text content; tool-call content was not validated");
return success();
}
JsonNode root;
try {
root = objectMapper.readTree(extractJson(text));
} catch (Exception e) {
auditLog.log("TripSafetyGuardrail", "RETRY", "Response is not valid JSON");
return retry("The response is not valid JSON. Please return a valid JSON object matching the ItineraryResult format.");
}
JsonNode itinerary = root == null ? null : root.path("itinerary");
if (itinerary == null || !itinerary.isArray() || itinerary.isEmpty()) {
auditLog.log("TripSafetyGuardrail", "RETRY", "Itinerary is missing or empty");
return retry("The trip plan must include a day-by-day itinerary. Please provide at least one day.");
}
List<String> dangerousMatches = findDangerousContent(root);
if (!dangerousMatches.isEmpty()) {
String matched = String.join(", ", dangerousMatches);
auditLog.log("TripSafetyGuardrail", "RETRY", "Dangerous content detected: " + matched);
return retry("The trip plan references potentially dangerous areas (" + matched
+ "). Please regenerate the plan avoiding these areas and suggesting safe alternatives.");
}
auditLog.log("TripSafetyGuardrail", "PASS", "Nonempty itinerary; no configured phrases in route overview or day descriptions");
return success();
}
private List<String> findDangerousContent(JsonNode root) {
List<String> matches = new ArrayList<>();
String routeOverview = root.path("routeOverview").asText("").toLowerCase();
checkForDangerousKeywords(routeOverview, matches);
JsonNode itinerary = root.path("itinerary");
if (itinerary.isArray()) {
for (JsonNode day : itinerary) {
String description = day.path("description").asText("").toLowerCase();
checkForDangerousKeywords(description, matches);
}
}
return matches;
}
private void checkForDangerousKeywords(String text, List<String> matches) {
for (String keyword : DANGEROUS_KEYWORDS) {
if (text.contains(keyword) && !matches.contains(keyword)) {
matches.add(keyword);
}
}
}
static String extractJson(String text) {
String trimmed = text.strip();
if (trimmed.startsWith("{")) {
return trimmed;
}
int start = trimmed.indexOf('{');
int end = trimmed.lastIndexOf('}');
if (start >= 0 && end > start) {
return trimmed.substring(start, end + 1);
}
return trimmed;
}
}
extractJson()finds the JSON inside a response, including one wrapped in Markdown fences.validate()checks for an itinerary, then scans the route overview and daily descriptions for the configured phrases.retry()requests another response. Its error message records the problem but is not sent to the model as corrective guidance.
For example, an empty itinerary array triggers another attempt before the response becomes an ItineraryResult.
What does an itinerary PASS mean?
PASS means the itinerary is nonempty and none of the configured phrases matched. It does not establish that the route is safe or has the requested number of days. Titles are not scanned, and a warning such as “avoid the conflict area” still matches the phrase list.
Blank text is logged as SKIP and allowed through without validation. Tool-call content is not inspected.
The following sequence illustrates a retry whose second response passes the implemented checks. The messages are descriptions of the interaction, not captured logs.
sequenceDiagram
participant A as Itinerary agent
participant M as Model
participant G as Itinerary guardrail
A->>M: Request itinerary
M-->>A: JSON containing a flagged phrase
A->>G: Validate response
G-->>A: Request another attempt
A->>M: Regenerate without added instructions
M-->>A: JSON passing the checks
A->>G: Validate response
G-->>A: Accept
Note over A: Deserialize as ItineraryResult
Rewriting and reprompting with successWith() and reprompt()
The vehicle guardrail checks whether a recommendation fits the customer’s group size and budget.
It checks the economy-budget rule first, then corrects small-vehicle recommendations for groups of four or more.
Create src/main/java/com/tripplanner/guardrails/TripAppropriatenessGuardrail.java:
package com.tripplanner.guardrails;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import dev.langchain4j.data.message.AiMessage;
import dev.langchain4j.guardrail.OutputGuardrail;
import dev.langchain4j.guardrail.OutputGuardrailRequest;
import dev.langchain4j.guardrail.OutputGuardrailResult;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import java.util.Set;
import java.util.Locale;
@ApplicationScoped
public class TripAppropriatenessGuardrail implements OutputGuardrail {
private static final Set<String> SMALL_VEHICLE_KEYWORDS = Set.of(
"sports car", "sport car", "coupé", "coupe", "convertible", "2-seater", "two-seater", "roadster");
private static final Set<String> LUXURY_BRANDS = Set.of(
"ferrari", "porsche", "lamborghini", "maserati", "bentley", "rolls-royce", "aston martin", "mclaren");
@Inject
GuardrailAuditLog auditLog;
@Inject
ObjectMapper objectMapper;
@Override
public OutputGuardrailResult validate(OutputGuardrailRequest guardrailRequest) {
String text = guardrailRequest.responseFromLLM().aiMessage().text();
if (text == null || text.isBlank()) {
auditLog.log("TripAppropriatenessGuardrail", "SKIP", "No text content; tool-call content was not validated");
return success();
}
JsonNode root;
try {
root = objectMapper.readTree(TripSafetyGuardrail.extractJson(text));
} catch (Exception e) {
auditLog.log("TripAppropriatenessGuardrail", "SKIP", "Response is not valid JSON; suitability was not validated");
return success();
}
if (root == null || !root.isObject() || !root.path("type").isTextual()
|| root.path("type").asText().isBlank() || !root.path("model").isTextual()
|| root.path("model").asText().isBlank()) {
auditLog.log("TripAppropriatenessGuardrail", "SKIP", "Vehicle type or model is missing; suitability was not validated");
return success();
}
var variables = guardrailRequest.requestParams().variables();
String budget = (String) variables.get("budget");
String tripType = (String) variables.get("tripType");
int travelers;
try {
// Prompt variables may contain text or numeric method arguments.
travelers = Integer.parseInt(String.valueOf(variables.get("travelers")));
} catch (NumberFormatException e) {
auditLog.log("TripAppropriatenessGuardrail", "SKIP", "Traveler count is missing or invalid; suitability was not validated");
return success();
}
if (budget == null || budget.isBlank() || tripType == null || tripType.isBlank()) {
auditLog.log("TripAppropriatenessGuardrail", "SKIP", "Trip variables are missing or incomplete; suitability was not validated");
return success();
}
String vehicleType = root.path("type").asText("").toLowerCase(Locale.ROOT);
String vehicleModel = root.path("model").asText("").toLowerCase(Locale.ROOT);
// Check the original model before a generic rewrite can hide a budget violation.
if (budget.toLowerCase(Locale.ROOT).contains("economy") && isLuxuryBrand(vehicleModel)) {
auditLog.log("TripAppropriatenessGuardrail", "REPROMPT",
"Luxury vehicle '" + vehicleModel + "' does not match economy budget");
return reprompt("The vehicle recommendation is a luxury vehicle but the budget is economy. "
+ "Please recommend an affordable, budget-friendly vehicle instead.",
"You are a vehicle advisor for road trips. You MUST recommend only budget-friendly, "
+ "affordable vehicles. Never suggest luxury, premium, or sports brands.");
}
if (travelers >= 4 && isSmallVehicle(vehicleType)) {
rewriteVehicle((ObjectNode) root, travelers, tripType);
auditLog.log("TripAppropriatenessGuardrail", "REWRITE",
"Vehicle type '" + vehicleType + "' is too small for " + travelers + " travelers; returned a generic category recommendation");
return successWith(AiMessage.from(root.toString()));
}
auditLog.log("TripAppropriatenessGuardrail", "PASS", "No configured small-vehicle or economy-brand rule matched");
return success();
}
private boolean isSmallVehicle(String vehicleType) {
return SMALL_VEHICLE_KEYWORDS.stream().anyMatch(vehicleType::contains);
}
private boolean isLuxuryBrand(String vehicleModel) {
return LUXURY_BRANDS.stream().anyMatch(vehicleModel::contains);
}
private void rewriteVehicle(ObjectNode vehicle, int travelers, String tripType) {
String replacement = switch (tripType.toLowerCase(Locale.ROOT)) {
case "adventure" -> "SUV";
case "business" -> "Estate";
default -> "MPV";
};
vehicle.put("type", replacement);
vehicle.put("model", (replacement.equals("MPV") ? "Family MPV" : replacement)
+ "; specific model subject to availability.");
vehicle.put("reasoning", "Vehicle corrected by guardrail: original recommendation was too small for "
+ travelers + " travelers. Suggested category: " + replacement
+ ". Confirm seating, luggage capacity, price, and availability with the rental provider.");
}
}
requestParams().variables()supplies the trip details for this agent call, so each recommendation is checked against the right group size and budget, including on retries.reprompt()asks the model for an affordable vehicle when a listed luxury brand conflicts with an economy budget.rewriteVehicle()replaces the type, model description, and reasoning. It chooses an SUV for adventure trips, an Estate for business trips, and an MPV otherwise.successWith()accepts that corrected JSON without another model call, before it becomes aTripPlan.VehicleRecommendation.
For a family, the replacement model is Family MPV; specific model subject to availability. The accompanying reason asks the customer to confirm capacity, price, and availability.
Order matters: a Ferrari sports car for four travelers on an economy budget triggers REPROMPT first. This prevents a generic rewrite from hiding the budget violation. The next answer may still need a group-size correction.
Limits of the vehicle check
The replacement is a generic category suggestion, not a checked rental offer. No inventory, seating specification, or price lookup supports it. The economy rule only recognizes its listed brands; a different model can still be unaffordable. These keyword checks cannot establish suitability for every group size or vehicle.
Registering guardrails with @OutputGuardrails
The agents need to run these checks whenever the model returns a recommendation.
Open src/main/java/com/tripplanner/agentic/agents/ItineraryPlannerAgent.java and add the highlighted imports and annotation:
package com.tripplanner.agentic.agents;
import com.tripplanner.guardrails.TripSafetyGuardrail;
import com.tripplanner.model.ItineraryResult;
import dev.langchain4j.agentic.Agent;
import dev.langchain4j.service.UserMessage;
import dev.langchain4j.service.guardrail.OutputGuardrails;
import io.quarkiverse.langchain4j.skills.Skills;
public interface ItineraryPlannerAgent {
@UserMessage("""
You are an expert trip itinerary planner.
Before answering, activate the skill named "{tripType}-trip".
Create a detailed day-by-day itinerary and a route overview for the trip.
Include a title, description, and overnight stop for each day.
Consider the travel dates when suggesting activities and seasonal attractions.
- Destination: {destination}
- Start date: {startDate}
- Duration: {days} days
- Trip type: {tripType}
- Additional preferences: {preferences}
""")
@Agent(description = "Creates a detailed day-by-day itinerary and route overview",
outputKey = "itineraryResult")
@OutputGuardrails(value = TripSafetyGuardrail.class, maxRetries = 3)
@Skills({"family-trip", "adventure-trip", "business-trip"})
ItineraryResult planItinerary(String destination,
String startDate,
String days,
String tripType,
String preferences);
}
Make the corresponding additions in src/main/java/com/tripplanner/agentic/agents/VehicleAdvisorAgent.java:
package com.tripplanner.agentic.agents;
import com.tripplanner.guardrails.TripAppropriatenessGuardrail;
import com.tripplanner.model.TripPlan;
import dev.langchain4j.agentic.Agent;
import dev.langchain4j.service.UserMessage;
import dev.langchain4j.service.guardrail.OutputGuardrails;
import io.quarkiverse.langchain4j.skills.Skills;
public interface VehicleAdvisorAgent {
@UserMessage("""
You are a vehicle specialist for road trips.
Before answering, activate the vehicle-selection skill.
Based on the skill guidance and the trip details below, recommend the most suitable vehicle.
Consider the destination terrain, trip type, number of travelers, and budget.
- Destination: {destination}
- Trip type: {tripType}
- Number of travelers: {travelers}
- Budget: {budget}
- Additional preferences: {preferences}
""")
@Agent(description = "Recommends the best vehicle for the trip based on destination, travelers, and budget",
outputKey = "vehicle")
@OutputGuardrails(value = TripAppropriatenessGuardrail.class, maxRetries = 3)
@Skills({"vehicle-selection"})
TripPlan.VehicleRecommendation recommendVehicle(String destination,
String tripType,
String travelers,
String budget,
String preferences);
}
Each @OutputGuardrails annotation connects the agent to its guardrail, which checks the response before deserialization. In this step’s dependency version, maxRetries = 3 allows the first response and two more attempts, as verified by the exhaustion tests below. These checks run alongside the existing prompts and skills within the same research workflow.
Mapping guardrail exceptions to HTTP responses
When an agent runs out of attempts, the customer needs a readable error explaining that planning failed.
Create src/main/java/com/tripplanner/resource/GuardrailExceptionMapper.java:
package com.tripplanner.resource;
import dev.langchain4j.agentic.agent.AgentInvocationException;
import dev.langchain4j.guardrail.GuardrailException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.ExceptionMapper;
import jakarta.ws.rs.ext.Provider;
import org.jboss.logging.Logger;
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.Set;
@Provider
public class GuardrailExceptionMapper implements ExceptionMapper<AgentInvocationException> {
private static final Logger LOG = Logger.getLogger(GuardrailExceptionMapper.class);
record ErrorResponse(String error, String message) {}
@Override
public Response toResponse(AgentInvocationException exception) {
Throwable cause = exception;
Set<Throwable> visited = Collections.newSetFromMap(new IdentityHashMap<>());
while (cause != null && visited.add(cause)) {
if (cause instanceof GuardrailException) {
LOG.warn("Trip planning rejected by a guardrail", exception);
return Response.status(422)
.entity(new ErrorResponse("guardrail_violation",
"The trip plan could not pass the recommendation checks. Please revise your trip details and try again."))
.type(MediaType.APPLICATION_JSON)
.build();
}
cause = cause.getCause();
}
LOG.error("Trip planning failed", exception);
return Response.serverError()
.entity(new ErrorResponse("planning_failed", "Could not generate the trip plan. Please try again later."))
.type(MediaType.APPLICATION_JSON)
.build();
}
}
The mapper searches the exception’s causes for a GuardrailException, returning HTTP 422 with guardrail_violation when it finds one. Other agent failures return HTTP 500 with planning_failed. Both responses include a customer-facing message, while the full exception stays in the server log.
The starter UI already displays these messages as text. An unrecognized error, malformed response, or network failure shows the generic “Could not generate the trip plan. Please try again later.” message.
Calculating rental prices with a tool
The cost estimator needs a calculator that multiplies a daily rental rate by the requested number of days. We’ll use fictional workshop prices in EUR.
Create src/main/java/com/tripplanner/agentic/tools/RentalPricingTool.java:
package com.tripplanner.agentic.tools;
import com.tripplanner.guardrails.RentalEstimateInputGuardrail;
import dev.langchain4j.agent.tool.Tool;
import io.quarkiverse.langchain4j.guardrails.ToolInputGuardrails;
import jakarta.enterprise.context.ApplicationScoped;
import org.jboss.logging.Logger;
import java.util.Map;
@ApplicationScoped
public class RentalPricingTool {
// Fictional workshop rates in whole euros per day, not live rental prices.
public static final Map<String, Integer> DAILY_RATES = Map.of(
"compact", 45, "estate", 65, "suv", 80, "mpv", 90);
private static final Logger LOG = Logger.getLogger(RentalPricingTool.class);
@Tool("Calculate a rental estimate in EUR for the given vehicle category and number of rental days.")
@ToolInputGuardrails(RentalEstimateInputGuardrail.class)
public RentalEstimate estimateRental(String category, int days) {
int dailyRate = DAILY_RATES.get(category);
int rentalTotal = dailyRate * days;
LOG.infof("Rental calculation executed: category=%s, days=%d, total=%d EUR", category, days, rentalTotal);
return new RentalEstimate(category, days, "EUR", dailyRate, rentalTotal);
}
public record RentalEstimate(String category, int days, String currency, int dailyRate, int rentalTotal) {}
}
The tool calculates a rental subtotal using the fictional prices in DAILY_RATES and returns it alongside the daily rate. Its log message lets us check whether the calculation ran. The @ToolInputGuardrails annotation connects it to the argument validator below.
For example, suv for five days returns 80 EUR per day and a 400 EUR rental subtotal. Other trip expenses are separate, and no external pricing service is called.
Rejecting invalid tool arguments
The model supplies the tool’s arguments, so we need to check the category and duration before calculating a price.
Create src/main/java/com/tripplanner/guardrails/RentalEstimateInputGuardrail.java:
package com.tripplanner.guardrails;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.tripplanner.agentic.tools.RentalPricingTool;
import io.quarkiverse.langchain4j.guardrails.ToolInputGuardrail;
import io.quarkiverse.langchain4j.guardrails.ToolInputGuardrailRequest;
import io.quarkiverse.langchain4j.guardrails.ToolInputGuardrailResult;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
@ApplicationScoped
public class RentalEstimateInputGuardrail implements ToolInputGuardrail {
@Inject
ObjectMapper objectMapper;
@Inject
GuardrailAuditLog auditLog;
@Override
public ToolInputGuardrailResult validate(ToolInputGuardrailRequest request) {
JsonNode arguments;
try {
arguments = objectMapper.reader().with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS)
.readTree(request.arguments());
} catch (JsonProcessingException | IllegalArgumentException e) {
return reject("Supply a JSON object with category and days.");
}
if (arguments == null || !arguments.isObject()) {
return reject("Supply a JSON object with category and days.");
}
JsonNode category = arguments.path("category");
if (!category.isTextual() || !RentalPricingTool.DAILY_RATES.containsKey(category.textValue())) {
return reject("Choose category compact, estate, suv, or mpv (lowercase).");
}
JsonNode days = arguments.path("days");
if (!days.isIntegralNumber() || !days.canConvertToInt() || days.intValue() < 1 || days.intValue() > 30) {
return reject("Set days to a whole number from 1 to 30.");
}
auditLog.log("RentalEstimateInputGuardrail", "PASS", "Rental arguments accepted");
return ToolInputGuardrailResult.success();
}
private ToolInputGuardrailResult reject(String reason) {
auditLog.log("RentalEstimateInputGuardrail", "REJECT", reason);
return ToolInputGuardrailResult.failure(reason);
}
}
validate()checks the raw JSON before Quarkus converts it to Java arguments. Categories must be exactlycompact,estate,suv, ormpv, and days must be an integer from 1 to 30.failure()blocks the calculation and returns the reason to the model as a tool error, giving it a chance to correct its request.
For example, days: 1.5 and days: "5" are both rejected. Neither is silently converted to a whole number.
Rejecting a tool call does not consume the output guardrail’s attempt allowance or automatically fail the HTTP request. The model can request another tool call within the same conversation. See the tool guardrails reference for more detail.
Giving the cost estimator access
The cost agent needs access to the calculator and instructions to use its prices in the estimate.
Open src/main/java/com/tripplanner/agentic/agents/CostEstimatorAgent.java and add the highlighted imports, prompt changes, annotation, and days parameter:
package com.tripplanner.agentic.agents;
import com.tripplanner.agentic.tools.RentalPricingTool;
import com.tripplanner.model.ItineraryResult;
import com.tripplanner.model.TripPlan;
import dev.langchain4j.agentic.Agent;
import dev.langchain4j.service.UserMessage;
import io.quarkiverse.langchain4j.ToolBox;
public interface CostEstimatorAgent {
@UserMessage("""
You are a travel cost estimation expert.
Based on the vehicle recommendation and planned itinerary, provide a detailed cost breakdown.
Vehicle: {vehicle}
Route: {itineraryResult}
Rental duration in days: {days}
Number of travelers: {travelers}
Budget range: {budget}
Call estimateRental before answering. Choose the closest supported category
(compact, estate, suv, or mpv) for the recommended vehicle and use the rental duration above.
If the tool rejects the arguments, correct them using its error message.
Do not invent a rental price if no valid estimate is available.
Use the returned dailyRate unchanged for vehiclePerDay, and include rentalTotal
exactly once in the total estimate. These are fictional workshop prices in EUR.
Estimate fuel, tolls, accommodation, food, and activities for the whole trip separately.
Use string format for all amounts (e.g., "€150/day").
""")
@ToolBox(RentalPricingTool.class)
@Agent(description = "Estimates all costs for the trip based on vehicle, itinerary, and budget",
outputKey = "costs")
TripPlan.CostEstimate estimateCosts(TripPlan.VehicleRecommendation vehicle,
ItineraryResult itineraryResult,
String days,
String travelers,
String budget);
}
@ToolBox makes the calculator available to the agent, and the new days parameter supplies the duration from the workflow’s shared scope. The prompt asks the model to use the returned daily rate and include the rental subtotal once, while estimating the other trip expenses separately.
Inspecting guardrail execution
If the application is not already running, start it from the project directory you chose above:
Open http://localhost:8080 and fill in the form:
- Destination:
Italian Riviera - Start date: a future date
- Duration:
5days - Travelers:
4 - Trip Type:
Family Vacation - Budget:
Moderate (€1,000–€2,500)
Click Generate Trip Plan, wait for it to finish, and check the terminal for guardrail decisions. You should see the INFO messages written by GuardrailAuditLog when both responses reach the final success branch.
🛡️ [TripSafetyGuardrail] PASS — Nonempty itinerary; no configured phrases in route overview or day descriptions
🛡️ [TripAppropriatenessGuardrail] PASS — No configured small-vehicle or economy-brand rule matched
Note
If you find the guardrail lines hard to spot, temporarily set both quarkus.langchain4j.openai.log-requests and quarkus.langchain4j.openai.log-responses to false and try again.
A PASS means the recommendation already met the rules. A REPROMPT means the guardrail sent the model corrective instructions and waited for another answer. A REWRITE means the guardrail replaced the response directly without another model call. If a REPROMPT appears, look for the subsequent guardrail decision to see whether the next answer passed.
The RentalEstimateInputGuardrail decisions are also recorded in the INFO messages above. A valid call should have a corresponding Rental calculation executed message, while a rejected call returns an error without entering that method. A later corrected call can produce its own calculation message, so follow the arguments for each attempt.
Open the Quarkus Dev UI, select Executions on the LangChain4j Agentic card, and expand the estimateCosts in the latest run. Look for an estimateRental call and inspect its category, duration, and result. For example, an accepted suv call for five days returns a daily rate of 80 EUR and a rental subtotal of 400 EUR. Compare the returned rate with the vehicle-per-day amount displayed in the browser, without treating the full trip total as the rental subtotal.
Observing a guardrail reprompt in the browser
The vehicle-selection skill guides the model toward sensible choices for most trips, but the guardrail’s budget rule operates independently of the skill. Let’s try to trigger it by requesting a luxury vehicle on an economy budget.
Open http://localhost:8080 and fill in the form:
- Destination:
Italian Riviera - Start date: a future date
- Duration:
7days - Travelers:
2 - Trip Type:
Romantic Getaway - Budget:
Economy (€500–€1,000) - Additional Preferences:
We want a Ferrari
Click Generate Trip Plan, wait for it to finish, and look for the guardrail decisions in the terminal.
When the model recommends a Ferrari on an economy budget, the vehicle guardrail should catch the issue and interrupt with a REPROMPT, which will send an amended prompt back to the model. Then once the model corrects its answer you should see a PASS. Once both agents complete, the cost estimator calls estimateRental and the tool input guardrail validates its arguments. A successful run produces all four lines below, though not necessarily in this order because the vehicle and itinerary agents run in parallel:
🛡️ [TripAppropriatenessGuardrail] REPROMPT — Luxury vehicle 'ferrari ...' does not match economy budget
🛡️ [TripAppropriatenessGuardrail] PASS — No configured small-vehicle or economy-brand rule matched
🛡️ [TripSafetyGuardrail] PASS — Nonempty itinerary; no configured phrases in route overview or day descriptions
🛡️ [RentalEstimateInputGuardrail] PASS — Rental arguments accepted
The RentalEstimateInputGuardrail PASS confirms the cost estimator passed valid arguments and the calculation ran. Open the Quarkus Dev UI, select Executions on the LangChain4j Agentic card, and expand the cost estimator entry in the latest run. Find the estimateRental tool call and inspect the category, duration, and returned dailyRate. The daily rate shown there is what the model used for vehiclePerDay in the browser.
Note
If the vehicle guardrail audit log shows PASS on the first attempt, the model read the economy budget and self-corrected before the guardrail needed to act. Try the request again or try to fiddle with the instructions.
When a guardrail exhausts all its retry or reprompt attempts without a passing response, the GuardrailExceptionMapper returns HTTP 422 and the browser displays: The trip plan could not pass the recommendation checks. Please revise your trip details and try again.
Observing a tool input guardrail rejection
The Duration field in the form accepts any number (the Miles of Smiles developers were perhaps a bit lazy 😉), so we can trigger the input guardrail simply by entering a value outside the tool’s accepted range.
Open http://localhost:8080, fill in the form with any destination, and set Duration to 45 days. Click Generate Trip Plan and look for the RentalEstimateInputGuardrail lines in the terminal:
🛡️ [RentalEstimateInputGuardrail] REJECT — Set days to a whole number from 1 to 30.
🛡️ [RentalEstimateInputGuardrail] PASS — Rental arguments accepted
There is no Rental calculation executed line after the rejected call since the guardrail blocked it before reaching the calculation. The model receives the rejection reason as a tool result and then decides what to do. For example, it could retry with arguments within the accepted range, and split the 45-day rental into two separate calls (30 days and 15 days) and combine the results itself. Each valid call then produces its own PASS and Rental calculation executed line.
The browser would still show a 45-day trip plan because the itinerary agent received the full duration from the form since the tool input guardrail protects the calculation, not the request.
Open the Dev UI Executions panel for the cost estimator, expand the latest run, and compare the rejected tool call with the corrected ones that follow it.
Verifying with tests
Because a live model will not reliably reproduce a specific bad recommendation on demand, the supplied tests use fixed responses to exercise each guardrail branch deterministically.
If you are continuing from Step 01, copy all test classes from section-3/step-02/src/test/java/com/tripplanner/guardrails, plus src/test/java/com/tripplanner/TripPlanningFailureTest.java and src/test/java/com/tripplanner/resource/GuardrailExceptionMapperTest.java, into the matching packages in your working copy. Copy section-3/step-02/src/test/frontend/app.test.cjs to src/test/frontend/app.test.cjs there as well. Keep the existing API-key fallback in src/test/resources/application.properties:
Guardrail unit tests — *GuardrailTest covers the output guardrails (rewrite, reprompt, retry decisions) and the rental pricing input guardrail (invalid arguments blocked, valid arguments reaching the calculation). An invalid duration is rejected with Input guardrail failed for tool estimateRental: Set days to a whole number from 1 to 30. A valid five-day SUV call logs Rental calculation executed: category=suv, days=5, total=400 EUR.
HTTP failure tests — TripPlanningFailureTest calls the real POST /trip/plan endpoint with scripted model responses. It checks that corrected vehicle fields reach the client, that exhausted guardrail attempts return HTTP 422, and that an unrelated agent failure returns HTTP 500. VehicleGuardrailConcurrencyTest exercises concurrent planning through the agent pipeline with scripted model responses. The exhausted-check cases assert this response body:
{
"error": "guardrail_violation",
"message": "The trip plan could not pass the recommendation checks. Please revise your trip details and try again."
}
Run the Step 02 test suite:
The default Surefire configuration runs guardrail unit tests, TripPlanningFailureTest, and GuardrailExceptionMapperTest. Baseline contract checks stay in Step 00.
Browser test — The Playwright test displays a family vehicle correction and error responses at desktop and mobile widths using intercepted responses, without model calls. Node.js and npm are needed; they are not application dependencies.
Use your application’s port in APP_URL if it differs from 8080. To watch the controlled vehicle card and failure messages in a real browser, rerun with SHOW_BROWSER=true added to the environment ($env:SHOW_BROWSER="true" in PowerShell). The test pauses briefly after each displayed result.
Taking it further
Child-seat rentals give us another use for tool guardrails. As an optional exercise, add a small fictional extras-pricing tool with seat identifiers and quantities. Its input guardrail could reject an unknown seat or a negative quantity before calculation. Fixed-response tests should check that rejected calls never execute and that accepted calls return the expected separate extras subtotal.
For output-guardrail practice, compare a recommended seat’s catalog limits with supplied child measurements and vehicle compatibility data. A fixed response recommending an unsuitable seat should be rejected even if its price is valid. Missing suitability data should prompt a request for details instead of accepting a guess.
To explore tool output guardrails instead, add a fictional internal sales note to a pricing result, such as the agency’s commission on a child-seat rental, and filter it with @ToolOutputGuardrails before it reaches the model. Extend the scripted test to check that the calculation ran but the internal note is absent from the tool result seen by the model. Unlike the input guardrail, this check runs after the tool has executed.
You can also add a test with a flagged phrase only in an itinerary title, then extend findDangerousContent() to check titles. Another useful case is a warning such as “avoid the conflict area”: the current phrase matching rejects it even though it advises the customer to stay away.
These experiments are optional. Since Step 04 continues from the original guardrail rules and retry allowance, keep any experimental rule changes in a separate working copy if you want to follow that baseline.
Troubleshooting
The rental estimate tool is missing or rejected
Check that the cost agent has @ToolBox(RentalPricingTool.class) and that its prompt asks for estimateRental. Inspect the tool-call arguments and error result. Categories must be exactly compact, estate, suv, or mpv, and days must be a JSON integer from 1 to 30. The tool checks the arguments the model supplies, not whether they match the original trip request, so compare those values when inspecting the execution.
The guardrail never triggers
The model may already produce output that passes these rules. Run the fixed-payload tests above to check specific branches, and inspect the terminal logs for SKIP decisions. A skipped check permits processing to continue without validating that recommendation.
An unsuitable response was accepted
Compare the original response with the fields and keywords checked by the guardrail. The itinerary scan ignores titles and cannot recognize hazards expressed in other words. It also permits null or blank text without inspecting tool-call content, but now records SKIP instead of claiming a pass.
The vehicle check permits blank text, JSON parsing failures, missing vehicle fields, and missing or incomplete trip variables without checking suitability. Each path logs SKIP. A later deserialization failure is still possible. The keyword lists do not cover every small or expensive vehicle, and a generic correction does not establish actual capacity or affordability. These paths need attention before using the sample to enforce rental policy.
Planning fails after retries
Check the audit message for the rejection reason, then inspect the HTTP response in the browser’s network panel and the server exception log. HTTP 422 with guardrail_violation identifies an actual guardrail failure in the cause chain. HTTP 500 with planning_failed identifies an unrelated wrapped agent failure. Client messages intentionally omit the internal exception details, which can include model content or provider information; take care when sharing logs.
OPENAI_API_KEY is not set
Set OPENAI_API_KEY in the shell used to start the application, then restart it. Keep the same model configuration used in Step 01.
What’s next?
The planner now checks its research recommendations and can calculate rental prices through a tool that rejects invalid arguments before execution. In Step 03, we’ll add evaluator agents that vote on the vehicle recommendation, an iterative refinement loop, and adaptive model selection that picks a more capable model as the recommendation improves.
Continue to Step 03 - Voting, Loops, and Adaptive Model Selection