Skip to content

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
Hold "Alt" / "Option" to enable pan & zoom

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
Hold "Alt" / "Option" to enable pan & zoom

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:

cd section-3/step-03
./mvnw quarkus:dev

cmd cd section-3\step-03 .\mvnw.cmd quarkus:dev

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:

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:

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:

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:

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 @Agent with a unique outputKey so the voting planner can read their individual results from the workflow scope.
  • The evaluators take the current vehicle recommendation 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:

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:

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;
    }
}
  • VotingStrategy is 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 from InitPlanningContext for use in later callbacks.
  • firstAction() clears the accumulated vote list and dispatches all evaluator subagents in parallel with call(subagents).
  • nextAction() is called once per completed parallel agent. Each call appends that agent’s result via context.previousAgentInvocation().output() and returns noOp() until all evaluators have reported in. Once the count matches the subagent list size, it passes all collected results to the strategy and returns done(result).
  • topology() returns PARALLEL so 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:

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());
    }
}
  • @PlannerAgent lists the three evaluator interfaces as subAgents and sets outputKey = "evaluation" so the aggregated score is available to the exit condition and the reviser.
  • @PlannerSupplier returns a new VotingPlanner with the aggregation strategy. aggregateVotes requires exactly three VehicleEvaluation results with scores in the 1–10 range and throws IllegalStateException on 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:

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:

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);
    }
}
  • DynamicModelSelector is a @Singleton CDI bean that injects both the default ChatModel and 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:

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;
    }
}
  • @LoopAgent lists VehicleEvaluators and VehicleReviser as subagents. Each iteration runs both: first the evaluators vote, then the reviser refines.
  • maxIterations = MAX_REVISIONS + 1 sets 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. shouldExit receives both the latest VehicleEvaluation and the AgenticScope. If the score reaches 7.5, it returns true. If the number of completed evaluations has exceeded MAX_REVISIONS without meeting the threshold, it throws TripQualityException, which propagates as a 422 with error code quality_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:

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:

TripPlannerSystem.java
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:

application.properties
# 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 enhancedModel is configured as a separate named model using gpt-4o. The @ModelName("enhancedModel") qualifier in DynamicModelSelector resolves 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:

./mvnw quarkus:dev
.\mvnw.cmd quarkus:dev

Open http://localhost:8080 and fill in the form:

  • Destination: Italian Riviera
  • Start date: a future date
  • Duration: 5 days
  • 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:

Score 6.3 > 6.0 — switching to enhanced model for final refinement

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.

The Dev UI topology view showing the planTrip sequence with the research phase, vehicleReviewLoop containing three parallel evaluators and a reviser, and estimateCosts

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.

The Dev UI execution view showing a completed trip plan

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:

./mvnw test -Dquarkus.http.test-port=0
.\mvnw.cmd test -Dquarkus.http.test-port=0

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