Step 03 - Voting, Loops, and Adaptive Model Selection
The guardrails from Step 02 catch obviously unsuitable recommendations, like suggesting a sports car for a family of five. They cannot however tell us whether the accepted vehicle is actually the best choice. E.g. is it comfortable enough for a long road trip? Does it fit the budget? Is it fuel-efficient for the planned route?
In this step we’ll add three evaluator agents that independently assess the vehicle recommendation, aggregate their scores with a custom voting pattern, and feed the result into a refinement loop that lets a reviser agent improve the recommendation until it meets a quality threshold. We’ll also add an adaptive model selection so that the reviser starts with a lightweight model and switches to a more capable one as the recommendation improves.
Parallel assessment with the Voting pattern
The voting pattern dispatches multiple agents in parallel, collects their independent assessments, and aggregates the results into a single decision. Unlike a simple parallel fan-out that merges structured outputs, voting applies a strategy to the collected responses, such as averaging scores, taking a majority, or applying any custom aggregation logic.
In production systems, voting is valuable because it distributes responsibility across agents that each have a narrow, well-defined scope. An agent focused entirely on cost will catch cost problems that a general-purpose evaluator might trade away against other concerns. The aggregation step makes those individual judgements visible, which also makes the system’s behaviour auditable, since you can inspect each agent’s score independently to understand why the overall result came out the way it did.
flowchart LR
accTitle: Voting pattern — fan-out, assess, aggregate
accDescr: The vehicle recommendation is sent to three evaluators in parallel. Each returns a score and suggestions. A voting strategy aggregates the scores into a single evaluation.
Vehicle[Vehicle recommendation] --> E1[Comfort evaluator]
Vehicle --> E2[Cost evaluator]
Vehicle --> E3[Fuel efficiency evaluator]
E1 -->|score + suggestions| Agg[Voting strategy]
E2 -->|score + suggestions| Agg
E3 -->|score + suggestions| Agg
Agg --> Result[Aggregated evaluation]
classDef evaluator fill:#e3f2fd,stroke:#1565c0,color:#0d3b66
classDef strategy fill:#fff3e0,stroke:#b56500,color:#593200
class E1,E2,E3 evaluator
class Agg strategy
We’ll implement this with a custom VotingPlanner that implements the Planner interface from LangChain4j. The planner dispatches all evaluator subagents in parallel using call(subagents), then collects their outputs from the workflow scope and passes them to a VotingStrategy for aggregation.
Iterative refinement with @LoopAgent
A single evaluation pass tells us how good the recommendation is, but it doesn’t improve it. We need a loop that runs the evaluators, checks whether the score meets our threshold, and if not, asks a reviser agent to improve the recommendation before evaluating again. A numeric score and an explicit exit condition also make quality verifiable, because you can write a test that asserts the system meets a defined standard rather than relying on manual review of every output.
flowchart TD
accTitle: Vehicle review loop — evaluate, revise, check
accDescr: The loop runs evaluators in parallel via the voting planner, then the reviser refines the recommendation. The exit condition checks the evaluation score after the full iteration — if it reaches the threshold the loop exits, otherwise another round begins.
Start[Vehicle from research phase] --> Eval[VehicleEvaluators — voting]
Eval --> Revise[VehicleReviser — improve recommendation]
Revise --> Check{Score ≥ 7.5?}
Check -->|Yes| Exit[Use refined vehicle]
Check -->|No| Eval
Exit --> Cost[CostEstimatorAgent]
classDef loop fill:#e8f5e9,stroke:#2e7d32,color:#16351a
classDef check fill:#fff3e0,stroke:#b56500,color:#593200
class Eval,Revise loop
class Check check
The @LoopAgent annotation wraps this cycle with a configurable maximum number of iterations. The @ExitCondition checks the aggregated evaluation score at the end of each iteration. If the score reaches 7.5, the loop exits and the refined vehicle moves on to cost estimation.
Adaptive model selection with @ChatModelSupplier
Not every iteration needs the same model. When the output is still rough, a smaller model can make broad improvements just as effectively as a larger one, at a fraction of the cost. Only once the score is already close to the threshold, and the reviser is making fine adjustments, does a more capable model justify the extra expense. This pattern is particularly relevant in systems that run quality loops at scale, where the cost difference between early and late iterations adds up quickly.
The @ChatModelSupplier annotation on the reviser agent delegates model selection to a DynamicModelSelector CDI bean. This bean injects both the base model (gpt-4o-mini) and an enhanced model (gpt-4o) and chooses between them based on the current evaluation score.
| Evaluation score | Model selected | Rationale |
|---|---|---|
| ≤ 6.0 | gpt-4o-mini (base) |
Broad improvements still needed, so a lighter model suffices |
| > 6.0 | gpt-4o (enhanced) |
Fine-tuning a near-ready recommendation benefits from more capability |
This pattern is identical to the one used in Section 2 Step 07 for dynamic model selection based on car value.
Prepare the working copy
As always, you have the option to keep working from the previous step, or work directly with the solution:
Continue in your Step 02 working copy and apply the changes below. Use the completed Step 03 project for comparison if you get stuck.
The completed project already contains the changes below. You can read through the implementation without editing, then join the exercise at Inspecting the voting loop.
Start dev mode if it is not already running:
Add the vehicle evaluation model
The evaluator agents need a shared return type to represent their assessment.
Create src/main/java/com/tripplanner/model/VehicleEvaluation.java:
package com.tripplanner.model;
public record VehicleEvaluation(
double score,
String suggestions
) {}
Each evaluator will return a score between 1 and 10, along with textual suggestions for improvement. The voting strategy will average the scores and concatenate the suggestions.
Create the evaluator agents
Each evaluator assesses the vehicle recommendation from a different perspective.
Create src/main/java/com/tripplanner/agentic/agents/ComfortEvaluator.java:
package com.tripplanner.agentic.agents;
import com.tripplanner.model.TripPlan;
import com.tripplanner.model.VehicleEvaluation;
import dev.langchain4j.agentic.Agent;
import dev.langchain4j.service.UserMessage;
public interface ComfortEvaluator {
@UserMessage("""
You are a comfort evaluator for road trip vehicles.
Rate the vehicle recommendation on a scale of 1-10 for passenger comfort,
considering interior space, luggage capacity, ride quality, and suitability
for the number of travelers and trip type.
Vehicle: {vehicle}
Trip type: {tripType}
Number of travelers: {travelers}
Duration: {days} days
""")
@Agent(description = "Evaluates vehicle comfort, space, and luggage capacity",
outputKey = "comfortEval")
VehicleEvaluation evaluateComfort(TripPlan.VehicleRecommendation vehicle,
String tripType,
String travelers,
String days);
}
Create src/main/java/com/tripplanner/agentic/agents/CostEvaluator.java:
package com.tripplanner.agentic.agents;
import com.tripplanner.model.TripPlan;
import com.tripplanner.model.VehicleEvaluation;
import dev.langchain4j.agentic.Agent;
import dev.langchain4j.service.UserMessage;
public interface CostEvaluator {
@UserMessage("""
You are a cost evaluator for road trip vehicles.
Rate the vehicle recommendation on a scale of 1-10 for cost efficiency,
considering rental price relative to budget, fuel economy, and overall
value for money for the trip duration.
Vehicle: {vehicle}
Budget: {budget}
Number of travelers: {travelers}
Duration: {days} days
""")
@Agent(description = "Evaluates vehicle rental cost and budget fit",
outputKey = "costEval")
VehicleEvaluation evaluateCost(TripPlan.VehicleRecommendation vehicle,
String budget,
String travelers,
String days);
}
Create src/main/java/com/tripplanner/agentic/agents/FuelEfficiencyEvaluator.java:
package com.tripplanner.agentic.agents;
import com.tripplanner.model.TripPlan;
import com.tripplanner.model.VehicleEvaluation;
import dev.langchain4j.agentic.Agent;
import dev.langchain4j.service.UserMessage;
public interface FuelEfficiencyEvaluator {
@UserMessage("""
You are a fuel efficiency evaluator for road trip vehicles.
Rate the vehicle recommendation on a scale of 1-10 for fuel efficiency,
considering fuel consumption, driving range, and environmental impact
for the planned trip duration and destination.
Vehicle: {vehicle}
Destination: {destination}
Duration: {days} days
Trip type: {tripType}
""")
@Agent(description = "Evaluates vehicle fuel consumption, range, and environmental impact",
outputKey = "fuelEval")
VehicleEvaluation evaluateFuelEfficiency(TripPlan.VehicleRecommendation vehicle,
String destination,
String days,
String tripType);
}
- Each evaluator uses
@Agentwith a uniqueoutputKeyso the voting planner can read their individual results from the workflow scope. - The evaluators take the current
vehiclerecommendation from the scope, plus trip context parameters for their specific assessment dimension. - All three return
VehicleEvaluation, the same record type, so the aggregation strategy can process them uniformly.
Implement the VotingPlanner
The VotingPlanner is a custom Planner implementation that dispatches evaluators in parallel and aggregates their results.
Create src/main/java/com/tripplanner/agentic/voting/VotingStrategy.java:
package com.tripplanner.agentic.voting;
import java.util.Collection;
@FunctionalInterface
public interface VotingStrategy {
Object aggregate(Collection<Object> votes);
}
Create src/main/java/com/tripplanner/agentic/voting/VotingPlanner.java:
package com.tripplanner.agentic.voting;
import dev.langchain4j.agentic.planner.Action;
import dev.langchain4j.agentic.planner.AgentInstance;
import dev.langchain4j.agentic.planner.AgenticSystemTopology;
import dev.langchain4j.agentic.planner.InitPlanningContext;
import dev.langchain4j.agentic.planner.Planner;
import dev.langchain4j.agentic.planner.PlanningContext;
import java.util.List;
import java.util.ArrayList;
public class VotingPlanner implements Planner {
private final VotingStrategy strategy;
private List<AgentInstance> subagents;
private final List<Object> votes = new ArrayList<>();
public VotingPlanner(VotingStrategy strategy) {
this.strategy = strategy;
}
@Override
public void init(InitPlanningContext context) {
this.subagents = context.subagents();
}
@Override
public Action firstAction(PlanningContext context) {
votes.clear();
return call(subagents);
}
@Override
public Action nextAction(PlanningContext context) {
// The framework calls this after each parallel agent completes.
votes.add(context.previousAgentInvocation().output());
return votes.size() == subagents.size() ? done(strategy.aggregate(votes)) : noOp();
}
@Override
public AgenticSystemTopology topology() {
return AgenticSystemTopology.PARALLEL;
}
}
VotingStrategyis a functional interface, so any lambda or method reference that takes a collection of votes and returns an aggregate can serve as the strategy.init()saves the subagent list fromInitPlanningContextfor use in later callbacks.firstAction()clears the accumulated vote list and dispatches all evaluator subagents in parallel withcall(subagents).nextAction()is called once per completed parallel agent. Each call appends that agent’s result viacontext.previousAgentInvocation().output()and returnsnoOp()until all evaluators have reported in. Once the count matches the subagent list size, it passes all collected results to the strategy and returnsdone(result).topology()returnsPARALLELso the Dev UI renders the evaluators as parallel branches.
Neither VotingPlanner nor VotingStrategy are library classes. You can adapt the strategy for any aggregation logic: majority vote, weighted average, or consensus.
Wire evaluators with @PlannerAgent
The @PlannerAgent annotation connects the evaluator subagents to our custom planner through a @PlannerSupplier method.
Create src/main/java/com/tripplanner/agentic/workflow/VehicleEvaluators.java:
package com.tripplanner.agentic.workflow;
import com.tripplanner.agentic.agents.ComfortEvaluator;
import com.tripplanner.agentic.agents.CostEvaluator;
import com.tripplanner.agentic.agents.FuelEfficiencyEvaluator;
import com.tripplanner.agentic.voting.VotingPlanner;
import com.tripplanner.model.VehicleEvaluation;
import dev.langchain4j.agentic.declarative.PlannerAgent;
import dev.langchain4j.agentic.declarative.PlannerSupplier;
import dev.langchain4j.agentic.planner.Planner;
import java.util.Collection;
public interface VehicleEvaluators {
@PlannerAgent(
name = "vehicleEvaluators",
description = "Dispatches evaluators in parallel and aggregates their votes",
outputKey = "evaluation",
subAgents = {
ComfortEvaluator.class,
CostEvaluator.class,
FuelEfficiencyEvaluator.class
})
VehicleEvaluation evaluate(String destination,
String tripType,
String travelers,
String days,
String budget);
@PlannerSupplier
static Planner planner() {
return new VotingPlanner(VehicleEvaluators::aggregateVotes);
}
static Object aggregateVotes(Collection<Object> votes) {
if (votes == null || votes.size() != 3) {
throw new IllegalStateException("Expected all three vehicle evaluations");
}
double totalScore = 0;
StringBuilder suggestions = new StringBuilder();
for (Object vote : votes) {
if (!(vote instanceof VehicleEvaluation eval) || !Double.isFinite(eval.score())
|| eval.score() < 1 || eval.score() > 10) {
throw new IllegalStateException("Each vehicle evaluator must return a score between 1 and 10");
}
totalScore += eval.score();
if (eval.suggestions() != null && !eval.suggestions().isBlank()) {
if (!suggestions.isEmpty()) suggestions.append("; ");
suggestions.append(eval.suggestions());
}
}
return new VehicleEvaluation(totalScore / votes.size(), suggestions.toString());
}
}
@PlannerAgentlists the three evaluator interfaces assubAgentsand setsoutputKey = "evaluation"so the aggregated score is available to the exit condition and the reviser.@PlannerSupplierreturns a newVotingPlannerwith the aggregation strategy.aggregateVotesrequires exactly threeVehicleEvaluationresults with scores in the 1–10 range and throwsIllegalStateExceptionon any malformed or missing result.- The method signature includes the trip context parameters that the individual evaluators need, and the framework propagates them through the workflow scope.
Add the vehicle reviser with @ChatModelSupplier
The reviser agent takes the current recommendation and evaluation feedback and produces an improved recommendation. It uses adaptive model selection to pick the right model for the current quality level.
Create src/main/java/com/tripplanner/agentic/agents/DynamicModelSelector.java:
package com.tripplanner.agentic.agents;
import com.tripplanner.model.VehicleEvaluation;
import dev.langchain4j.model.chat.ChatModel;
import io.quarkiverse.langchain4j.ModelName;
import io.quarkus.logging.Log;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
@Singleton
public class DynamicModelSelector {
private static final double ENHANCED_MODEL_THRESHOLD = 6.0;
@Inject
ChatModel baseModel;
@Inject
@ModelName("enhancedModel")
ChatModel enhancedModel;
public ChatModel select(VehicleEvaluation evaluation) {
if (evaluation != null && evaluation.score() > ENHANCED_MODEL_THRESHOLD) {
Log.infof("Score %.1f > %.1f — switching to enhanced model for final refinement",
evaluation.score(), ENHANCED_MODEL_THRESHOLD);
return enhancedModel;
}
return baseModel;
}
}
Create src/main/java/com/tripplanner/agentic/agents/VehicleReviser.java:
package com.tripplanner.agentic.agents;
import com.tripplanner.model.TripPlan;
import com.tripplanner.guardrails.TripAppropriatenessGuardrail;
import dev.langchain4j.service.guardrail.OutputGuardrails;
import com.tripplanner.model.VehicleEvaluation;
import dev.langchain4j.agentic.Agent;
import dev.langchain4j.agentic.declarative.ChatModelSupplier;
import dev.langchain4j.model.chat.ChatModel;
import dev.langchain4j.service.UserMessage;
import io.quarkiverse.langchain4j.agentic.runtime.CdiBean;
public interface VehicleReviser {
@UserMessage("""
You are a vehicle recommendation specialist.
Revise the current vehicle recommendation based on the evaluation feedback.
Keep the same format but improve the choice to address the evaluators' suggestions.
Current recommendation: {vehicle}
Evaluation score: {evaluation}
Trip type: {tripType}
Number of travelers: {travelers}
Budget: {budget}
Destination: {destination}
Additional preferences: {preferences}
Preserve the original traveler, budget, and preference constraints.
""")
@OutputGuardrails(value = TripAppropriatenessGuardrail.class, maxRetries = 3)
@Agent(description = "Revises the vehicle recommendation based on evaluation feedback",
outputKey = "vehicle")
TripPlan.VehicleRecommendation revise(TripPlan.VehicleRecommendation vehicle,
VehicleEvaluation evaluation,
String tripType,
String travelers,
String budget,
String destination,
String preferences);
@ChatModelSupplier
static ChatModel chatModel(@CdiBean DynamicModelSelector modelSelector,
VehicleEvaluation evaluation) {
return modelSelector.select(evaluation);
}
}
DynamicModelSelectoris a@SingletonCDI bean that injects both the defaultChatModeland a named@ModelName("enhancedModel")model, choosing between them based on the current evaluation score. The same pattern is used in Section 2 Step 07.@OutputGuardrails(TripAppropriatenessGuardrail.class, maxRetries = 3)applies the same content guardrail from Step 02 to each revised recommendation, retrying up to three times if a revision violates it.- The reviser’s
outputKey = "vehicle"overwrites the vehicle in the scope, so the cost estimator and all downstream agents receive the refined version automatically.
Wrap the review cycle with @LoopAgent
The loop wraps the evaluators and reviser into an iterative cycle with an exit condition.
Create src/main/java/com/tripplanner/agentic/workflow/VehicleReviewLoop.java:
package com.tripplanner.agentic.workflow;
import com.tripplanner.agentic.agents.VehicleReviser;
import com.tripplanner.model.TripPlan;
import com.tripplanner.model.VehicleEvaluation;
import com.tripplanner.model.TripQualityException;
import dev.langchain4j.agentic.scope.AgenticScope;
import dev.langchain4j.agentic.declarative.ExitCondition;
import dev.langchain4j.agentic.declarative.LoopAgent;
public interface VehicleReviewLoop {
int MAX_REVISIONS = 3;
@LoopAgent(
name = "vehicleReviewLoop",
description = "Iteratively evaluates and refines the vehicle recommendation",
outputKey = "vehicle",
// One initial evaluation plus an evaluation after each permitted revision.
maxIterations = MAX_REVISIONS + 1,
subAgents = {
VehicleEvaluators.class,
VehicleReviser.class
})
TripPlan.VehicleRecommendation reviewVehicle(String destination,
String startDate,
String days,
String tripType,
String travelers,
String budget,
String preferences);
@ExitCondition(
testExitAtLoopEnd = false,
description = "Exits when the average evaluation score reaches 7.5")
static boolean shouldExit(VehicleEvaluation evaluation, AgenticScope scope) {
if (evaluation != null && evaluation.score() >= 7.5) return true;
// Checked after evaluation, before another revision can leave an unscored candidate.
if (scope.agentInvocations(VehicleEvaluators.class).size() > MAX_REVISIONS) throw new TripQualityException();
return false;
}
}
@LoopAgentlistsVehicleEvaluatorsandVehicleReviseras subagents. Each iteration runs both: first the evaluators vote, then the reviser refines.maxIterations = MAX_REVISIONS + 1sets the ceiling at four iterations — one initial evaluation plus one after each of the three permitted revisions.@ExitCondition(testExitAtLoopEnd = false)checks the condition immediately after each evaluation, before the reviser runs again.shouldExitreceives both the latestVehicleEvaluationand theAgenticScope. If the score reaches 7.5, it returnstrue. If the number of completed evaluations has exceededMAX_REVISIONSwithout meeting the threshold, it throwsTripQualityException, which propagates as a 422 with error codequality_not_met.- The loop’s
outputKey = "vehicle"writes the final vehicle back to the scope, overwriting the original from the research phase.
Before the loop can throw TripQualityException, you need the exception class itself.
Create src/main/java/com/tripplanner/model/TripQualityException.java:
package com.tripplanner.model;
public class TripQualityException extends RuntimeException {
public static final String CODE = "quality_not_met";
public static final String MESSAGE = "The vehicle recommendation did not meet the quality threshold after three revisions. Please revise your trip details and try again.";
public TripQualityException() {
super(MESSAGE);
}
}
The CODE and MESSAGE constants are used by the exception mapper and the frontend to display a consistent error when the loop exhausts its revision budget.
Update the main workflow
Open src/main/java/com/tripplanner/agentic/workflow/TripPlannerSystem.java and add VehicleReviewLoop.class to the subAgents array, between ResearchPhase and CostEstimatorAgent:
package com.tripplanner.agentic.workflow;
import com.tripplanner.agentic.agents.CostEstimatorAgent;
import com.tripplanner.model.ItineraryResult;
import com.tripplanner.model.TripPlan;
import dev.langchain4j.agentic.declarative.Output;
import dev.langchain4j.agentic.declarative.SequenceAgent;
import dev.langchain4j.agentic.observability.MonitoredAgent;
public interface TripPlannerSystem extends MonitoredAgent {
@SequenceAgent(
outputKey = "tripPlan",
subAgents = {
ResearchPhase.class,
VehicleReviewLoop.class,
CostEstimatorAgent.class
})
TripPlan planTrip(String destination,
String startDate,
String days,
String tripType,
String travelers,
String budget,
String preferences);
@Output
static TripPlan output(TripPlan.VehicleRecommendation vehicle,
ItineraryResult itineraryResult,
TripPlan.CostEstimate costs) {
return new TripPlan(
vehicle,
itineraryResult.routeOverview(),
itineraryResult.itinerary(),
costs);
}
}
The sequence now runs: parallel research → voting evaluation loop → cost estimation. The @Output method is unchanged because it still assembles the final TripPlan from vehicle, itineraryResult, and costs. The loop simply refines which vehicle reaches the cost estimator.
Configure adaptive model selection
Update src/main/resources/application.properties to add the enhanced model configuration:
# LLM Configuration (base model)
quarkus.langchain4j.openai.api-key=${OPENAI_API_KEY}
quarkus.langchain4j.openai.chat-model.model-name=gpt-4o
quarkus.langchain4j.openai.chat-model.temperature=0.7
quarkus.langchain4j.openai.timeout=120
# Enhanced model for adaptive selection (vehicle refinement)
quarkus.langchain4j.enhancedModel.chat-model.provider=openai
quarkus.langchain4j.openai.enhancedModel.api-key=${OPENAI_API_KEY}
quarkus.langchain4j.openai.enhancedModel.chat-model.model-name=gpt-4.1
quarkus.langchain4j.openai.enhancedModel.chat-model.temperature=0.7
quarkus.langchain4j.openai.enhancedModel.timeout=120
# Skills configuration
quarkus.langchain4j.skills.directories=classpath:skills
%dev.quarkus.live-reload.watched-resources=skills/family-trip/SKILL.md
# Dev logging
quarkus.langchain4j.openai.log-requests=true
quarkus.langchain4j.openai.log-responses=true
- The base model is now
gpt-4o-mini, which is cost-effective for most agents. - The
enhancedModelis configured as a separate named model usinggpt-4o. The@ModelName("enhancedModel")qualifier inDynamicModelSelectorresolves to this configuration. - Both models share the same
OPENAI_API_KEY. The enhanced model has its own temperature and timeout settings.
Inspecting the voting loop
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 look for the evaluation and model selection messages in the terminal. You should see lines like:
This indicates the DynamicModelSelector chose the enhanced model for that iteration’s revision. The evaluator scores and the loop iteration count appear in the agentic execution log.
Open the Quarkus Dev UI and select Topology on the LangChain4j Agentic card. The graph shows the full agent structure: the planTrip sequence contains the parallel research phase, the vehicleReviewLoop, and estimateCosts. Inside the loop you can see the vehicleEvaluators node, rendered as a parallel fan-out with the three evaluators, and the vehicleReviser.
Switch to Executions on the same card and expand the latest run. The execution trace shows timing for each agent. In the example below, the research phase completed in 7.1 seconds (parallel), the vehicle review loop ran one iteration in 3.9 seconds — the three evaluators scored the initial recommendation at 8.5, 7.5, and 8.5 (average 8.17), the reviser refined the vehicle, and the loop exited because 8.17 ≥ 7.5. The cost estimator then priced the refined vehicle in 1.9 seconds.
Compare the vehicle recommendation before and after the loop by inspecting the scope values. The initial recommendation from the research phase should differ from the refined one produced by the reviser.
Verifying with tests
The supplied tests verify the voting aggregation, pipeline assembly, and end-to-end HTTP contract without calling a live model.
Run the Step 03 test suite:
Aggregation test — VehicleEvaluationAggregatorTest verifies the averaging strategy: three scores produce the correct average, blank suggestions are skipped, and a missing, incomplete, or out-of-range vote list throws IllegalStateException.
Pipeline test — TripPlanContractTest checks that the workflow’s subAgents array includes VehicleReviewLoop between ResearchPhase and CostEstimatorAgent. The scripted model returns high evaluation scores so the loop exits after one iteration, and the HTTP endpoint returns the expected JSON contract.
Failure tests — TripPlanningFailureTest and the guardrail tests from Step 02 continue to pass with the added loop. Each scripted model profile includes an @Alternative for the @ModelName("enhancedModel") model so the DynamicModelSelector resolves correctly without a live API key.
Taking it further
As an optional exercise, try adding a fourth evaluator that assesses the vehicle’s suitability for the planned route terrain (mountain roads, coastal highways, city driving). Use a different outputKey and update the aggregateVotes method to handle four votes.
You can also experiment with the exit condition threshold — lowering it to 6.0 makes the loop exit faster, while raising it to 9.0 may consume all three iterations. Add logging inside the shouldExit method to observe the score progression across iterations.
For a more advanced experiment, try a weighted voting strategy where the comfort evaluator counts double for family trips and the cost evaluator counts double for economy budgets. Pass the trip type into the aggregation to select the weights.
Troubleshooting
The enhanced model is not configured
Check that application.properties has the quarkus.langchain4j.enhancedModel.* properties and that OPENAI_API_KEY is set. Both the base and enhanced models use the same API key. If the enhanced model configuration is missing, the @ModelName("enhancedModel") injection will fail at startup.
The loop always runs all three iterations
Check the evaluator prompts and the exit condition threshold. If the evaluators consistently return low scores, the reviser may not improve the recommendation enough. Try lowering the threshold in VehicleReviewLoop.shouldExit() or adjusting the evaluator prompts to be more generous. Inspect the evaluation scores in the Dev UI execution view.
Tests fail with missing enhancedModel bean
Check that each test profile’s getEnabledAlternatives() includes both ScriptedModel.class and ScriptedEnhancedModel.class. The ScriptedEnhancedModel is an @Alternative @ModelName("enhancedModel") bean that delegates to the default scripted model.
OPENAI_API_KEY is not set
Set OPENAI_API_KEY in the shell used to start the application, then restart it. Both models require the same key.
What’s next?
The planning pipeline now evaluates vehicle recommendations through a voting pattern, refines them iteratively, and adapts model selection based on quality. In Step 04, we’ll wrap the entire planning pipeline in an event-driven Quarkus Flow workflow with Kafka and CloudEvents so the customer can approve or reject a proposed trip.
Continue to Step 04 - Event-Driven Workflows with Quarkus Flow

