Skip to content

Step 06 - Human-in-the-Loop Pattern

Human-in-the-Loop Pattern

In the previous step, you built a distributed agent system using Agent-to-Agent (A2A) communication, where the DispositionAgent ran as a remote service.

However, that system made autonomous decisions about vehicle disposition without human oversight. What if you need human approval before executing high-stakes decisions, especially for valuable assets?

In this step, you’ll learn about the Human-in-the-Loop (HITL) pattern - a critical approach where AI agents pause execution to request human approval before proceeding with significant actions.


New Requirement from Miles of Smiles Management: Human Approval for High-Value Dispositions

The Miles of Smiles management team has identified a risk: the system is making autonomous disposition decisions on high-value vehicles without human oversight.

They want to implement a human approval gate with these requirements:

  1. Value threshold: Any vehicle worth more than $15,000 requires human approval before disposition
  2. Two-phase workflow:
    • Phase 1: AI creates a disposition proposal
    • Phase 2: Human reviews and approves or rejects the proposal
  3. Execution control: Only execute approved dispositions
  4. Audit trail: Track approval status and reasoning for compliance

This ensures that expensive vehicles aren’t scrapped or sold without proper human review.


What You’ll Learn

In this step, you will:

  • Understand the Human-in-the-Loop (HITL) pattern and when to use it
  • Implement a two-phase approval workflow (proposal → review → execution)
  • Create a DispositionProposalAgent that generates proposals
  • Build a HumanApprovalAgent using LangChain4j’s @HumanInTheLoop annotation
  • Modify the FleetSupervisorAgent to route high-value vehicles through approval
  • Add approval tracking to the data model
  • See how HITL provides safety and control in autonomous systems

Understanding Human-in-the-Loop

What is Human-in-the-Loop?

Human-in-the-Loop (HITL) is a pattern where:

  • AI agents perform analysis and create recommendations
  • Execution pauses to request human approval
  • Humans review proposals and make final decisions
  • System proceeds only after approval

HITL vs. Fully Autonomous

Aspect Fully Autonomous Human-in-the-Loop
Speed Fast, immediate Slower, waits for human
Scalability Unlimited Limited by human capacity
Accuracy Consistent but may miss edge cases Human judgment for complex cases
Accountability System responsibility Human responsibility
Cost Lower operational cost Higher due to human involvement

The Two-Phase Workflow

sequenceDiagram
    participant System as Agentic System
    participant Proposal as Proposal Agent
    participant Human as Human Reviewer
    participant Execution as Execution Agent

    System->>Proposal: Analyze situation
    Proposal->>Proposal: Create recommendation
    Proposal-->>System: Proposal ready

    System->>Human: Request approval
    Note over Human: Human reviews<br/>proposal details
    Human-->>System: APPROVED/REJECTED

    alt Approved
        System->>Execution: Execute proposal
        Execution-->>System: Action completed
    else Rejected
        System->>System: Fallback action
        Note over System: Route to alternative<br/>processing path
    end
Hold "Alt" / "Option" to enable pan & zoom

What is Being Added?

We’re enhancing our car management system with:

  • DispositionProposalAgent: Creates disposition proposals for review
  • HumanApprovalAgent: Uses LangChain4j’s @HumanInTheLoop annotation to pause workflow execution and wait for a human decision through the UI
  • Updated FleetSupervisorAgent: Routes high-value vehicles through the approval workflow
  • Enhanced CarConditions: Tracks approval status and reasoning
  • Value-based routing: Different paths for high-value vs. low-value vehicles

The Complete HITL Architecture

graph TB
    Start([Car Return]) --> A[CarProcessingWorkflow<br/>Sequential]

    A --> B[Step 1: FeedbackWorkflow<br/>Parallel Analysis]
    B --> B1[CleaningFeedbackAgent]
    B --> B2[MaintenanceFeedbackAgent]
    B --> B3[DispositionFeedbackAgent]
    B1 --> BEnd[All feedback complete]
    B2 --> BEnd
    B3 --> BEnd

    BEnd --> C[Step 2: FleetSupervisorAgent<br/>Autonomous Orchestration]
    C --> C1{Disposition<br/>Required?}

    C1 -->|Yes| PA[PricingAgent<br/>Estimate Value]
    PA --> VCheck{Value > $15k?}

    VCheck -->|Yes - HIGH VALUE| Proposal[DispositionProposalAgent<br/>Create Proposal]
    Proposal --> Approval[HumanApprovalAgent<br/>@HumanInTheLoop]
    Approval --> ApprovalCheck{Approved?}
    ApprovalCheck -->|Yes| Execute[Execute Disposition]
    ApprovalCheck -->|No| Fallback[Route to Maintenance/Cleaning]

    VCheck -->|No - LOW VALUE| Direct[DispositionAgent<br/>Direct Decision]
    Direct --> Execute

    C1 -->|No| Other[MaintenanceAgent or CleaningAgent]

    Execute --> CEnd[Supervisor Decision]
    Fallback --> CEnd
    Other --> CEnd

    CEnd --> D[Step 3: CarConditionFeedbackAgent<br/>Final Summary]
    D --> End([Updated Car with Approval Status])

    style A fill:#90EE90
    style B fill:#87CEEB
    style C fill:#FFB6C1
    style D fill:#90EE90
    style Proposal fill:#FFD700
    style Approval fill:#FF6B6B
    style VCheck fill:#FFA07A
    style ApprovalCheck fill:#FFA07A
    style Start fill:#E8E8E8
    style End fill:#E8E8E8
Hold "Alt" / "Option" to enable pan & zoom

The Key Innovation:

The FleetSupervisorAgent now implements value-based routing:

  • High-value path (>$15,000): PricingAgent → DispositionProposalAgent → HumanApprovalAgent → Execute if approved
  • Low-value path (≤$15,000): PricingAgent → DispositionAgent → Execute directly
  • Fallback: If approval rejected, route to maintenance or cleaning instead

Implementing the Human-in-the-Loop Pattern

Let’s build the HITL system step by step.

Create the DispositionProposalAgent

This agent creates disposition proposals that will be reviewed by humans.

Create src/main/java/com/carmanagement/agentic/agents/DispositionProposalAgent.java:

DispositionProposalAgent.java
package com.carmanagement.agentic.agents;

import dev.langchain4j.agentic.Agent;
import dev.langchain4j.service.SystemMessage;
import dev.langchain4j.service.UserMessage;

/**
 * Agent that creates disposition proposals for vehicles requiring disposition.
 * This agent analyzes the vehicle and creates a proposal that will be reviewed
 * by the HumanApprovalAgent if the vehicle value exceeds the threshold.
 */
public interface DispositionProposalAgent {

    @SystemMessage("""
        You are a car disposition specialist for a car rental company.
        Your job is to create a disposition proposal based on the car's value, condition, age, and damage.

        Disposition Options:
        - SCRAP: Car is beyond economical repair or has severe safety concerns
        - SELL: Car has value but is aging out of the fleet or has moderate damage
        - DONATE: Car has minimal value but could serve a charitable purpose
        - KEEP: Car is worth keeping in the fleet

        Decision Criteria:
        - If estimated repair cost > 50% of car value: Consider SCRAP or SELL
        - If car is over 5 years old with significant damage: SCRAP
        - If car is 3-5 years old in fair condition: SELL
        - If car has low value (<$5,000) but functional: DONATE
        - If car is valuable and damage is minor: KEEP

        Your response must include:
        1. Proposed Action with unique marker: __SCRAP__ or __SELL__ or __DONATE__ or __KEEP__
        2. Reasoning: Clear explanation of your recommendation

        Format your response as:
        Proposed Action: __[SCRAP/SELL/DONATE/KEEP]__
        Reasoning: [Your detailed explanation]

        CRITICAL: Use double underscores around the action (e.g., __KEEP__ not KEEP)
        """)
    @UserMessage("""
        Create a disposition proposal for this vehicle:
        - Make: {carMake}
        - Model: {carModel}
        - Year: {carYear}
        - Car Number: {carNumber}
        - Current Condition: {carCondition}
        - Estimated Value: {carValue}
        - Damage/Feedback: {rentalFeedback}

        Provide your disposition proposal with clear reasoning.
        """)
    @Agent(outputKey = "dispositionProposal", description = "Creates disposition proposals for vehicles requiring disposition")
    String createDispositionProposal(
            String carMake,
            String carModel,
            Integer carYear,
            Integer carNumber,
            String carCondition,
            String carValue,
            String rentalFeedback);
}

Why two disposition agents?

You might wonder why we have both DispositionProposalAgent and DispositionAgent (from Step 5). They serve different purposes: DispositionProposalAgent creates recommendations for human review on high-value vehicles (>$15K), while DispositionAgent makes autonomous decisions on lower-value vehicles. Think of it like needing manager approval for expensive purchases but having autonomy for small ones.

Key Points:

  • Creates proposals rather than final decisions
  • Uses same decision criteria as DispositionAgent
  • Output format includes “Proposed Action” and “Reasoning”
  • Stored in AgenticScope with key dispositionProposal

Create the HumanApprovalAgent

This agent implements Human-in-the-Loop using LangChain4j’s @HumanInTheLoop annotation. Instead of relying on a separate tool, the agent method itself pauses workflow execution until a human makes a decision through the UI.

Create src/main/java/com/carmanagement/agentic/agents/HumanApprovalAgent.java:

HumanApprovalAgent.java
package com.carmanagement.agentic.agents;

import com.carmanagement.model.ApprovalProposal;
import com.carmanagement.service.ApprovalService;
import dev.langchain4j.agentic.Agent;
import dev.langchain4j.agentic.declarative.HumanInTheLoop;
import io.quarkus.arc.Arc;
import io.quarkus.logging.Log;

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;

public interface HumanApprovalAgent {

    @Agent(outputKey = "approvalDecision", description = "Coordinates human approval for high-value vehicle dispositions using the requestHumanApproval tool")
    @HumanInTheLoop(outputKey = "approvalDecision", description = "Coordinates human approval for high-value vehicle dispositions using the requestHumanApproval tool")
    static String reviewDispositionProposal(
            String carMake,
            String carModel,
            Integer carYear,
            Integer carNumber,
            String carValue,
            String dispositionProposal,
            String dispositionReason,
            String carCondition,
            String rentalFeedback
    ) {

        Log.infof("🛑 HITL Tool: Creating approval proposal for car %d - %s %s %s",
                carNumber, carYear, carMake, carModel);
        Log.info("⏸️  WORKFLOW PAUSED - Waiting for human approval decision via UI");

        ApprovalService approvalService = Arc.container().instance(ApprovalService.class).get();

        try {
            // Create proposal and get CompletableFuture that completes when human decides
            CompletableFuture<ApprovalProposal> approvalFuture =
                    approvalService.createProposalAndWaitForDecision(
                            carNumber, carMake, carModel, carYear, carValue,
                            dispositionProposal, dispositionReason, carCondition, rentalFeedback
                    );

            // BLOCK HERE until human makes decision (with 5 minute timeout)
            ApprovalProposal result = approvalFuture.get(5, TimeUnit.MINUTES);

            Log.infof("▶️  WORKFLOW RESUMED - Human decision received: %s", result.decision);

            // Format response for the agent
            return String.format("""
                Human Decision: %s
                Reason: %s
                Approved By: %s
                Decision Time: %s
                """,
                    result.decision,
                    result.approvalReason != null ? result.approvalReason : "No reason provided",
                    result.approvedBy != null ? result.approvedBy : "Unknown",
                    result.decidedAt != null ? result.decidedAt.toString() : "Unknown"
            );

        } catch (TimeoutException e) {
            Log.error("⏱️  TIMEOUT: No human decision received within 5 minutes, defaulting to REJECTED");
            return """
                Human Decision: REJECTED
                Reason: Timeout - No human decision received within 5 minutes. Defaulting to rejection for safety.
                Approved By: System (Timeout)
                """;
        } catch (Exception e) {
            Log.errorf(e, "❌ ERROR: Failed to get human approval for car %d", carNumber);
            return String.format("""
                Human Decision: REJECTED
                Reason: Error occurred while waiting for human approval: %s
                Approved By: System (Error)
                """, e.getMessage());
        }
    }
}

Key Points:

  • The @HumanInTheLoop annotation from LangChain4j marks this agent method as requiring human interaction before completing
  • The method body contains the blocking logic directly — no separate tool class is needed
  • Calls ApprovalService.createProposalAndWaitForDecision() which returns a CompletableFuture
  • Workflow execution pauses by calling .get(5, TimeUnit.MINUTES) on the future
  • Human sees pending approval in the UI and clicks Approve/Reject
  • The future completes, workflow resumes with the human’s decision
  • Returns structured decision: APPROVED/REJECTED with reasoning
  • Stored in AgenticScope with key approvalDecision

Why @HumanInTheLoop instead of a tool?

In previous versions of this workshop, the human approval logic lived in a separate HumanApprovalTool class that the agent would invoke. LangChain4j’s @HumanInTheLoop annotation simplifies this by letting you place the blocking logic directly in the agent method. The annotation signals to the framework that this agent requires human interaction, keeping everything in one place and eliminating the extra tool class.

The ApprovalService

The ApprovalService manages the CompletableFuture instances that pause and resume workflow execution. This is the bridge between the HumanApprovalAgent and the REST endpoints that the UI calls.

Create src/main/java/com/carmanagement/service/ApprovalService.java:

ApprovalService.java
package com.carmanagement.service;

import com.carmanagement.model.ApprovalProposal;
import com.carmanagement.model.ApprovalProposal.ApprovalStatus;
import io.quarkus.logging.Log;
import io.quarkus.vertx.ConsumeEvent;
import io.smallrye.mutiny.Uni;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.persistence.EntityManager;
import jakarta.transaction.Transactional;
import static jakarta.transaction.Transactional.TxType;

import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

/**
 * Service for managing approval proposals and the Human-in-the-Loop workflow.
 * This service handles the async nature of human approvals - creating proposals,
 * waiting for human decisions, and continuing workflow execution.
 */
@ApplicationScoped
public class ApprovalService {

    @Inject
    EntityManager entityManager;

    /**
     * Map to store CompletableFutures waiting for approval decisions.
     * Key: carNumber, Value: CompletableFuture that completes when decision is made
     */
    private final Map<Integer, CompletableFuture<ApprovalProposal>> pendingApprovals = new ConcurrentHashMap<>();

    /**
     * Executor for async proposal creation to ensure transaction commits before blocking
     */
    private final ExecutorService executor = Executors.newCachedThreadPool();

    /**
     * Create a new approval proposal and return a CompletableFuture that will complete
     * when a human makes a decision.
     * 
     * @param carNumber The car number
     * @param carMake Car make
     * @param carModel Car model
     * @param carYear Car year
     * @param carValue Estimated car value
     * @param proposedDisposition Proposed action (SCRAP, SELL, DONATE, KEEP)
     * @param dispositionReason Reasoning for the proposal
     * @param carCondition Current car condition
     * @param rentalFeedback Rental feedback
     * @return CompletableFuture that completes when human approves/rejects
     */
    public CompletableFuture<ApprovalProposal> createProposalAndWaitForDecision(
            Integer carNumber,
            String carMake,
            String carModel,
            Integer carYear,
            String carValue,
            String proposedDisposition,
            String dispositionReason,
            String carCondition,
            String rentalFeedback) {

        // Check if there's already a pending proposal for this car
        ApprovalProposal existing = ApprovalProposal.findPendingByCarNumber(carNumber);
        if (existing != null) {
            Log.warnf("Proposal already exists for car %d, returning existing future", carNumber);
            return pendingApprovals.computeIfAbsent(carNumber, k -> new CompletableFuture<>());
        }

        // Create CompletableFuture first
        CompletableFuture<ApprovalProposal> future = new CompletableFuture<>();
        pendingApprovals.put(carNumber, future);

        // Create proposal in separate thread with its own transaction
        // This ensures the transaction commits BEFORE we return the future
        executor.submit(() -> {
            try {
                createProposalInNewTransaction(carNumber, carMake, carModel, carYear, carValue,
                        proposedDisposition, dispositionReason, carCondition, rentalFeedback);
                Log.info("✅ Proposal creation transaction committed - now visible to queries");
            } catch (Exception e) {
                Log.errorf(e, "Failed to create proposal for car %d", carNumber);
                future.completeExceptionally(e);
                pendingApprovals.remove(carNumber);
            }
        });

        return future;
    }

    @Transactional(TxType.REQUIRES_NEW)
    void createProposalInNewTransaction(
            Integer carNumber,
            String carMake,
            String carModel,
            Integer carYear,
            String carValue,
            String proposedDisposition,
            String dispositionReason,
            String carCondition,
            String rentalFeedback) {

        // Create new proposal
        ApprovalProposal proposal = new ApprovalProposal();
        proposal.carNumber = carNumber;
        proposal.carMake = carMake;
        proposal.carModel = carModel;
        proposal.carYear = carYear;
        proposal.carValue = carValue;
        proposal.proposedDisposition = proposedDisposition;
        proposal.dispositionReason = dispositionReason;
        proposal.carCondition = carCondition;
        proposal.rentalFeedback = rentalFeedback;
        proposal.status = ApprovalStatus.PENDING;
        proposal.createdAt = LocalDateTime.now();

        proposal.persist();
        entityManager.flush();

        Log.infof("Created approval proposal ID=%d for car %d - %s %s %s (Value: %s, Proposed: %s)",
                proposal.id, carNumber, carYear, carMake, carModel, carValue, proposedDisposition);
        Log.info("⏸️  WORKFLOW PAUSED - Waiting for human approval decision");
        Log.infof("Proposal persisted with ID: %d, status: %s", proposal.id, proposal.status);
    }

    /**
     * Process a human's approval decision and complete the waiting CompletableFuture.
     * This resumes the workflow execution.
     * 
     * @param proposalId The proposal ID
     * @param approved Whether approved or rejected
     * @param reason Human's reasoning
     * @param approvedBy Who made the decision
     * @return The updated proposal
     */
    @Transactional(TxType.REQUIRES_NEW)
    public ApprovalProposal processDecision(Integer proposalId, boolean approved, String reason, String approvedBy) {
        ApprovalProposal proposal = ApprovalProposal.findById(proposalId);
        if (proposal == null) {
            throw new IllegalArgumentException("Proposal not found: " + proposalId);
        }

        if (proposal.status != ApprovalStatus.PENDING) {
            throw new IllegalStateException("Proposal is not pending: " + proposalId);
        }

        // Update proposal with decision
        proposal.status = approved ? ApprovalStatus.APPROVED : ApprovalStatus.REJECTED;
        proposal.decision = approved ? "APPROVED" : "REJECTED";
        proposal.approvalReason = reason;
        proposal.approvedBy = approvedBy;
        proposal.decidedAt = LocalDateTime.now();

        proposal.persist();

        Log.infof("Human decision received for car %d: %s - %s",
                proposal.carNumber, proposal.decision, reason);
        Log.info("▶️  WORKFLOW RESUMED - Continuing with approval decision");

        // Complete the CompletableFuture to resume workflow
        CompletableFuture<ApprovalProposal> future = pendingApprovals.remove(proposal.carNumber);
        if (future != null) {
            future.complete(proposal);
        }

        return proposal;
    }

    /**
     * Get all pending approval proposals.
     */
    public List<ApprovalProposal> getPendingProposals() {
        return ApprovalProposal.findAllPending();
    }

    /**
     * Get a specific proposal by ID.
     */
    public ApprovalProposal getProposal(Integer proposalId) {
        return ApprovalProposal.findById(proposalId);
    }

    /**
     * Check if there's a pending approval for a car.
     */
    public ApprovalProposal getPendingProposalForCar(Integer carNumber) {
        return ApprovalProposal.findPendingByCarNumber(carNumber);
    }
}

Key Points:

  • Stores CompletableFuture<ApprovalProposal> in a map keyed by car number
  • createProposalAndWaitForDecision() creates the future and returns it
  • Proposal is persisted in a separate transaction to ensure it’s visible to UI queries
  • processDecision() completes the future when human makes a decision
  • This completion resumes the workflow that was blocked on .get()

Create the ApprovalProposal Entity

This entity stores proposals in the database so the UI can display them.

Create src/main/java/com/carmanagement/model/ApprovalProposal.java:

ApprovalProposal.java
package com.carmanagement.model;

import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Column;
import java.time.LocalDateTime;

/**
 * Entity representing a disposition proposal awaiting human approval.
 * This is the core of the Human-in-the-Loop pattern - proposals are stored
 * and the workflow pauses until a human makes an approval decision.
 */
@Entity
public class ApprovalProposal extends PanacheEntity {

    /**
     * The car number this proposal is for
     */
    @Column(nullable = false)
    public Integer carNumber;

    /**
     * Car make
     */
    public String carMake;

    /**
     * Car model
     */
    public String carModel;

    /**
     * Car year
     */
    public Integer carYear;

    /**
     * Estimated car value
     */
    public String carValue;

    /**
     * Proposed disposition action (SCRAP, SELL, DONATE, KEEP)
     */
    @Column(nullable = false)
    public String proposedDisposition;

    /**
     * Reasoning for the proposed disposition
     */
    @Column(length = 2000)
    public String dispositionReason;

    /**
     * Current car condition
     */
    @Column(length = 1000)
    public String carCondition;

    /**
     * Rental feedback that triggered this proposal
     */
    @Column(length = 2000)
    public String rentalFeedback;

    /**
     * Current status of the approval
     */
    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    public ApprovalStatus status = ApprovalStatus.PENDING;

    /**
     * Human's decision (APPROVED or REJECTED)
     */
    public String decision;

    /**
     * Human's reasoning for their decision
     */
    @Column(length = 1000)
    public String approvalReason;

    /**
     * Who approved/rejected (for audit trail)
     */
    public String approvedBy;

    /**
     * When the proposal was created
     */
    @Column(nullable = false)
    public LocalDateTime createdAt = LocalDateTime.now();

    /**
     * When the decision was made
     */
    public LocalDateTime decidedAt;

    /**
     * Find pending proposal for a specific car
     */
    public static ApprovalProposal findPendingByCarNumber(Integer carNumber) {
        return find("carNumber = ?1 and status = ?2", carNumber, ApprovalStatus.PENDING).firstResult();
    }

    /**
     * Find all pending proposals
     */
    public static java.util.List<ApprovalProposal> findAllPending() {
        return find("status", ApprovalStatus.PENDING).list();
    }

    /**
     * Approval status enum
     */
    public enum ApprovalStatus {
        PENDING,
        APPROVED,
        REJECTED
    }
}

Create the ApprovalResource

This REST resource allows the UI to fetch pending approvals and submit decisions.

Create src/main/java/com/carmanagement/resource/ApprovalResource.java:

ApprovalResource.java
package com.carmanagement.resource;

import com.carmanagement.model.ApprovalProposal;
import com.carmanagement.service.ApprovalService;
import jakarta.inject.Inject;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import io.quarkus.logging.Log;

import java.util.List;
import java.util.Map;

/**
 * REST resource for managing approval proposals.
 * Provides endpoints for humans to view and approve/reject proposals.
 */
@Path("/api/approvals")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class ApprovalResource {

    @Inject
    ApprovalService approvalService;

    /**
     * Get all pending approval proposals.
     * This is called by the UI to display proposals awaiting human decision.
     */
    @GET
    @Path("/pending")
    public List<ApprovalProposal> getPendingProposals() {
        return approvalService.getPendingProposals();
    }

    /**
     * Get a specific proposal by ID.
     */
    @GET
    @Path("/{proposalId}")
    public Response getProposal(@PathParam("proposalId") Integer proposalId) {
        ApprovalProposal proposal = approvalService.getProposal(proposalId);
        if (proposal == null) {
            return Response.status(Response.Status.NOT_FOUND)
                    .entity(Map.of("error", "Proposal not found"))
                    .build();
        }
        return Response.ok(proposal).build();
    }

    /**
     * Approve a proposal.
     * This is called when a human clicks the "Approve" button in the UI.
     * 
     * @param proposalId The proposal ID
     * @param request Request body containing reason and approvedBy
     */
    @POST
    @Path("/{proposalId}/approve")
    public Response approveProposal(
            @PathParam("proposalId") Integer proposalId,
            Map<String, String> request) {

        try {
            String reason = request.getOrDefault("reason", "Approved by human reviewer");
            String approvedBy = request.getOrDefault("approvedBy", "Workshop User");

            Log.infof("Approval request received for proposal %d by %s", proposalId, approvedBy);

            ApprovalProposal proposal = approvalService.processDecision(
                    proposalId, true, reason, approvedBy);

            return Response.ok(proposal).build();
        } catch (IllegalArgumentException e) {
            return Response.status(Response.Status.NOT_FOUND)
                    .entity(Map.of("error", e.getMessage()))
                    .build();
        } catch (IllegalStateException e) {
            return Response.status(Response.Status.BAD_REQUEST)
                    .entity(Map.of("error", e.getMessage()))
                    .build();
        } catch (Exception e) {
            Log.error("Error approving proposal", e);
            return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
                    .entity(Map.of("error", "Error processing approval: " + e.getMessage()))
                    .build();
        }
    }

    /**
     * Reject a proposal.
     * This is called when a human clicks the "Reject" button in the UI.
     *
     * @param proposalId The proposal ID
     * @param request Request body containing reason and approvedBy
     */
    @POST
    @Path("/{proposalId}/reject")
    public Response rejectProposal(
            @PathParam("proposalId") Integer proposalId,
            Map<String, String> request) {

        try {
            String reason = request.getOrDefault("reason", "Rejected by human reviewer");
            String approvedBy = request.getOrDefault("approvedBy", "Workshop User");

            Log.infof("Rejection request received for proposal %d by %s", proposalId, approvedBy);

            ApprovalProposal proposal = approvalService.processDecision(
                    proposalId, false, reason, approvedBy);

            return Response.ok(proposal).build();
        } catch (IllegalArgumentException e) {
            return Response.status(Response.Status.NOT_FOUND)
                    .entity(Map.of("error", e.getMessage()))
                    .build();
        } catch (IllegalStateException e) {
            return Response.status(Response.Status.BAD_REQUEST)
                    .entity(Map.of("error", e.getMessage()))
                    .build();
        } catch (Exception e) {
            Log.error("Error rejecting proposal", e);
            return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
                    .entity(Map.of("error", "Error processing rejection: " + e.getMessage()))
                    .build();
        }
    }

    /**
     * Make a decision on a proposal with explicit KEEP_CAR or DISPOSE_CAR action.
     * Simplified approach where the UI directly specifies the desired outcome.
     *
     * @param proposalId The proposal ID
     * @param request Request body containing decision (KEEP_CAR or DISPOSE_CAR), reason, and approvedBy
     */
    @POST
    @Path("/{proposalId}/decide")
    public Response decideProposal(
            @PathParam("proposalId") Integer proposalId,
            Map<String, String> request) {

        try {
            String decision = request.get("decision"); // KEEP_CAR or DISPOSE_CAR
            String reason = request.getOrDefault("reason", "Decision by human reviewer");
            String approvedBy = request.getOrDefault("approvedBy", "Workshop User");

            if (decision == null || (!decision.equals("KEEP_CAR") && !decision.equals("DISPOSE_CAR"))) {
                return Response.status(Response.Status.BAD_REQUEST)
                        .entity(Map.of("error", "Decision must be either KEEP_CAR or DISPOSE_CAR"))
                        .build();
            }

            Log.infof("Decision '%s' received for proposal %d by %s", decision, proposalId, approvedBy);

            // Store the decision in the reason so the workflow can use it
            String fullReason = decision + ": " + reason;

            // We still use the approve/reject mechanism, but the decision is in the reason
            ApprovalProposal proposal = approvalService.processDecision(
                    proposalId, true, fullReason, approvedBy);

            return Response.ok(proposal).build();
        } catch (IllegalArgumentException e) {
            return Response.status(Response.Status.NOT_FOUND)
                    .entity(Map.of("error", e.getMessage()))
                    .build();
        } catch (IllegalStateException e) {
            return Response.status(Response.Status.BAD_REQUEST)
                    .entity(Map.of("error", e.getMessage()))
                    .build();
        } catch (Exception e) {
            Log.error("Error processing decision", e);
            return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
                    .entity(Map.of("error", "Error processing decision: " + e.getMessage()))
                    .build();
        }
    }
}

REST API Endpoints:

  • GET /api/approvals/pending - Returns all pending approval proposals
  • POST /api/approvals/{id}/approve - Approve a proposal
  • POST /api/approvals/{id}/reject - Reject a proposal

How the HITL Flow Works End-to-End

  1. The FleetSupervisorAgent detects a high-value vehicle and invokes the DispositionProposalAgent
  2. The proposal is passed to the HumanApprovalAgent, which is annotated with @HumanInTheLoop
  3. Inside the agent method, ApprovalService.createProposalAndWaitForDecision() persists the proposal to the database and returns a CompletableFuture
  4. The agent method blocks on future.get(5, TimeUnit.MINUTES) — the workflow pauses here
  5. The UI polls GET /api/approvals/pending and displays the proposal to the human reviewer
  6. The human clicks Approve or Reject, which calls the corresponding REST endpoint
  7. ApprovalService.processDecision() completes the CompletableFuture with the decision
  8. The agent method unblocks, formats the decision, and returns it
  9. The workflow resumes with the human’s decision

Update the FleetSupervisorAgent

Modify the supervisor to implement value-based routing with the approval workflow.

Update src/main/java/com/carmanagement/agentic/agents/FleetSupervisorAgent.java:

FleetSupervisorAgent.java
package com.carmanagement.agentic.agents;

import dev.langchain4j.agentic.declarative.SupervisorAgent;
import dev.langchain4j.agentic.declarative.SupervisorRequest;

/**
 * Supervisor agent that orchestrates the entire car processing workflow.
 * Coordinates feedback analysis agents and action agents based on car condition.
 * Implements human-in-the-loop pattern for high-value vehicle dispositions.
 */
public interface FleetSupervisorAgent {

    @SupervisorAgent(
        outputKey = "supervisorDecision",
        subAgents = {
            PricingAgent.class,
            DispositionProposalAgent.class,
            HumanApprovalAgent.class,
            DispositionAgent.class,
            MaintenanceAgent.class,
            CleaningAgent.class
        }
    )
    String superviseCarProcessing(
        String carMake,
        String carModel,
        Integer carYear,
        Integer carNumber,
        String carCondition,
        String rentalFeedback,
        String cleaningFeedback,
        String maintenanceFeedback,
        String cleaningRequest,
        String maintenanceRequest,
        String dispositionRequest
    );

    @SupervisorRequest()
    static String request(
        String carMake,
        String carModel,
        Integer carYear,
        Integer carNumber,
        String carCondition,
        String cleaningRequest,
        String maintenanceRequest,
        String dispositionRequest,
        String rentalFeedback
    ) {
        boolean dispositionRequired = dispositionRequest != null &&
                                     dispositionRequest.toUpperCase().contains("DISPOSITION_REQUIRED");

        String noDispositionMessage = """
            Disposition is not required. 
            Proceed with normal maintenance and cleaning workflow. 
            If cleaning or maintenance is required, invoke the appropriate agents.
                """;

        String dispositionMessage = """
           DISPOSITION_REQUIRED

           Follow these steps:

           1. Get value from PricingAgent (keep $ format)
           2. IF value > $15,000 (HIGH-VALUE):
              - Invoke DispositionProposalAgent → HumanApprovalAgent (workflow pauses)
              - APPROVED: Use AI recommendation → KEEP→"KEEP_CAR", DISPOSE→"DISPOSE_CAR"
              - REJECTED: Opposite of AI → KEEP→"DISPOSE_CAR", DISPOSE→"KEEP_CAR"
           3. IF value ≤ $15,000 (LOW-VALUE):
              - Invoke DispositionAgent directly
              - KEEP→"KEEP_CAR", SCRAP/SELL/DONATE→"DISPOSE_CAR"
           4. IF "KEEP_CAR": Invoke MaintenanceAgent/CleaningAgent as needed

           CRITICAL: End with KEEP_CAR or DISPOSE_CAR
           """;

        return """
            You are a fleet supervisor for a car rental company. You coordinate action agents based on feedback analysis.

            The feedback has already been analyzed and you have these inputs:
            - cleaningRequest: What cleaning is needed (or "CLEANING_NOT_REQUIRED")
            - maintenanceRequest: What maintenance is needed (or "MAINTENANCE_NOT_REQUIRED")
            - dispositionRequest: Whether severe damage requires disposition (or "DISPOSITION_NOT_REQUIRED")

            Your job is to invoke the appropriate ACTION agents for this car

            Car: """ + carYear + " " + carMake + " " + carModel + " (#" + carNumber + ")" + """

            Current Condition: """ + carCondition + """

            Rental Feedback: """ + rentalFeedback + """

            Cleaning Request: """ + cleaningRequest + """

            Maintenance Request: """ + maintenanceRequest + """

            Disposition Request: """ + (dispositionRequired ? dispositionMessage : noDispositionMessage);
    }
}

Key Changes:

  • Added DispositionProposalAgent and HumanApprovalAgent to subAgents
  • Implemented two-path routing based on vehicle value
  • High-value path: Proposal → Approval → Execute if approved
  • Low-value path: Direct disposition decision
  • Stores approval status in AgenticScope for tracking

Update the CarConditions Model

Add approval tracking fields to the data model.

Update src/main/java/com/carmanagement/model/CarConditions.java:

CarConditions.java
package com.carmanagement.model;

/**
 * Record representing the conditions of a car.
 *
 * @param generalCondition    A description of the car's general condition
 * @param carAssignment       Indicates the action required (DISPOSITION, MAINTENANCE, CLEANING, or NONE)
 * @param dispositionStatus   Status of disposition decision (DISPOSITION_APPROVED, DISPOSITION_REJECTED, or DISPOSITION_NOT_REQUIRED)
 * @param dispositionReason   Reason for disposition decision
 */
public record CarConditions(
    String generalCondition,
    CarAssignment carAssignment,
    String dispositionStatus,
    String dispositionReason
) {
    /**
     * Constructor for backward compatibility without disposition fields.
     */
    public CarConditions(String generalCondition, CarAssignment carAssignment) {
        this(generalCondition, carAssignment, "DISPOSITION_NOT_REQUIRED", null);
    }
}

Key Points:

  • Added dispositionStatus field (DISPOSITION_APPROVED/DISPOSITION_REJECTED/DISPOSITION_NOT_REQUIRED)
  • Added dispositionReason field for audit trail
  • Backward-compatible constructor for existing code

Update Application Configuration

Add configuration for the approval threshold.

Update src/main/resources/application.properties:

# Human-in-the-Loop configuration
# Threshold for requiring human approval on high-value dispositions
car-management.approval.threshold=15000

This makes the threshold configurable without code changes.


Try the Complete Solution

Now let’s see the Human-in-the-Loop pattern in action!

Keep the Remote A2A Service Running

Before starting the step-06 application, make sure you have the remote A2A service from step-05 running in a separate terminal.

Why? The DispositionAgent used in this step is still running as a remote service (from step-05) and communicates via Agent-to-Agent (A2A) protocol.

Port Configuration:

  • Remote A2A service (step-05): http://localhost:8888
  • Main application (step-06): http://localhost:8080

To start the remote service:

cd section-2/step-05/remote-a2a-agent
./mvnw quarkus:dev

Keep this terminal running while you work with step-06. Both services need to be running for the Human-in-the-Loop workflow to work correctly.

Start the Application

  1. Navigate to the step-06 directory:
cd section-2/step-06
  1. Start the application:
./mvnw quarkus:dev
mvnw quarkus:dev
  1. Open http://localhost:8080

Test HITL Scenarios

Try these scenarios to see how the approval workflow handles different vehicle values:

Scenario 1: High-Value Vehicle Requiring Approval

Enter the following text in the feedback field for the Honda Civic:

The car was in a serious collision. Front end is completely destroyed and airbags deployed.

What happens:

flowchart TD
    Start([Input: Serious collision<br/>Front end destroyed])

    Start --> FW[FeedbackWorkflow<br/>Detects: DISPOSITION_REQUIRED]

    FW --> FSA[FleetSupervisorAgent<br/>Orchestration]
    FSA --> PA[PricingAgent]
    PA --> Value[Estimate: ~$18,000<br/>2020 Honda Civic]

    Value --> Check{Value > $15,000?}
    Check -->|Yes| Proposal[DispositionProposalAgent<br/>Creates Proposal]
    Proposal --> PropResult[Proposed: SCRAP<br/>Reasoning: Severe damage]

    PropResult --> Human[HumanApprovalAgent<br/>@HumanInTheLoop pauses workflow]
    Human --> Decision{Decision}
    Decision -->|APPROVED| Execute[Execute SCRAP]
    Decision -->|REJECTED| Fallback[Route to Maintenance]

    Execute --> Result([Status: PENDING_DISPOSITION<br/>Approval: APPROVED])
    Fallback --> Result2([Status: IN_MAINTENANCE<br/>Approval: REJECTED])

    style FW fill:#FAE5D3
    style FSA fill:#D5F5E3
    style PA fill:#F9E79F
    style Proposal fill:#FFD700
    style Human fill:#FF6B6B
    style Check fill:#FFA07A
    style Decision fill:#FFA07A
    style Result fill:#D2B4DE
    style Result2 fill:#D2B4DE
Hold "Alt" / "Option" to enable pan & zoom

Expected Result:

  • PricingAgent estimates value at ~$18,000 (above threshold)
  • DispositionProposalAgent creates SCRAP proposal
  • HumanApprovalAgent pauses the workflow (via @HumanInTheLoop) and waits for human input
  • Human reviews the proposal in the UI and clicks Approve or Reject
  • Workflow resumes with the decision
  • Status: PENDING_DISPOSITION if approved, IN_MAINTENANCE if rejected

Scenario 2: High-Value Vehicle - Approval Rejected

Enter the following text in the Mercedes Benz feedback field:

Minor fender bender, small dent in rear bumper

What happens:

flowchart TD
    Start([Input: Minor fender bender<br/>small dent])

    Start --> FW[FeedbackWorkflow<br/>Detects: DISPOSITION_REQUIRED]

    FW --> FSA[FleetSupervisorAgent]
    FSA --> PA[PricingAgent]
    PA --> Value[Estimate: ~$25,000<br/>2021 Mercedes Benz]

    Value --> Check{Value > $15,000?}
    Check -->|Yes| Proposal[DispositionProposalAgent<br/>Creates Proposal]
    Proposal --> PropResult[Proposed: SELL or KEEP<br/>Minor damage]

    PropResult --> Human[HumanApprovalAgent<br/>@HumanInTheLoop pauses workflow]
    Human --> Decision[Decision: REJECTED<br/>Too valuable for minor damage]

    Decision --> Fallback[Route to Maintenance<br/>Repair instead]
    Fallback --> Result([Status: IN_MAINTENANCE<br/>Approval: REJECTED])

    style FW fill:#FAE5D3
    style FSA fill:#D5F5E3
    style PA fill:#F9E79F
    style Proposal fill:#FFD700
    style Human fill:#FF6B6B
    style Check fill:#FFA07A
    style Result fill:#D2B4DE
Hold "Alt" / "Option" to enable pan & zoom

Expected Result:

  • PricingAgent estimates value at ~$25,000 (high value)
  • DispositionProposalAgent suggests SELL or KEEP
  • HumanApprovalAgent pauses workflow, human REJECTS (too valuable for disposition with minor damage)
  • Fallback: Routes to MaintenanceAgent instead
  • Status: IN_MAINTENANCE
  • Disposition status: DISPOSITION_REJECTED with reasoning

Scenario 3: Low-Value Vehicle - No Approval Needed

Enter the following text in the Ford F-150 feedback field (Maintenance Returns tab):

The truck is totaled, completely inoperable, very old

What happens:

flowchart TD
    Start([Input: Totaled truck<br/>very old])

    Start --> FW[FeedbackWorkflow<br/>Detects: DISPOSITION_REQUIRED]

    FW --> FSA[FleetSupervisorAgent]
    FSA --> PA[PricingAgent]
    PA --> Value[Estimate: ~$8,000<br/>2019 Ford F-150, totaled]

    Value --> Check{Value > $15,000?}
    Check -->|No| Direct[DispositionAgent<br/>Direct Decision]
    Direct --> Decision[Decision: SCRAP<br/>Beyond economical repair]

    Decision --> Result([Status: PENDING_DISPOSITION<br/>Approval: NOT_REQUIRED])

    style FW fill:#FAE5D3
    style FSA fill:#D5F5E3
    style PA fill:#F9E79F
    style Direct fill:#87CEEB
    style Check fill:#FFA07A
    style Result fill:#D2B4DE
Hold "Alt" / "Option" to enable pan & zoom

Expected Result:

  • PricingAgent estimates value at ~$8,000 (below threshold)
  • Skips approval workflow entirely (low value)
  • DispositionAgent makes direct SCRAP decision
  • Status: PENDING_DISPOSITION
  • Disposition status: DISPOSITION_NOT_REQUIRED

Check the Logs

Watch the console output to see the approval workflow execution:

FeedbackWorkflow executing...
  |- DispositionFeedbackAgent: DISPOSITION_REQUIRED
FleetSupervisorAgent orchestrating...
  |- PricingAgent: Estimated value $18,000
  |- Value check: $18,000 > $15,000 -> Approval required
  |- DispositionProposalAgent: Proposed SCRAP
  |- HumanApprovalAgent (@HumanInTheLoop): Workflow paused...
  |- Waiting for human decision via UI...
  |- Human decision received: APPROVED
  |- Workflow resumed
CarConditionFeedbackAgent updating...
  |- Disposition status: DISPOSITION_APPROVED

Notice how the workflow truly pauses at the HumanApprovalAgent and only resumes after the human makes a decision in the UI!


Why Human-in-the-Loop Matters

Safety and Control

HITL provides a safety net for autonomous systems:

  • Prevents costly mistakes: Human review catches edge cases
  • Builds trust: Gradual transition from manual to autonomous
  • Maintains accountability: Clear human responsibility for critical decisions

Compliance and Audit

Many industries require human oversight:

  • Financial services: Large transactions need approval
  • Healthcare: Treatment decisions require physician review
  • Legal: Contract terms need lawyer approval
  • Audit trails: Track who approved what and when

Balancing Automation and Control

HITL lets you tune the automation level:

graph LR
    A[Fully Manual] --> B[HITL - High Threshold]
    B --> C[HITL - Low Threshold]
    C --> D[Fully Autonomous]

    style A fill:#FF6B6B
    style B fill:#FFD700
    style C fill:#87CEEB
    style D fill:#90EE90
Hold "Alt" / "Option" to enable pan & zoom
  • Start with low threshold (approve everything)
  • Gradually increase threshold as confidence grows
  • Eventually move to fully autonomous for routine cases
  • Keep HITL for high-stakes decisions

Optional: Implement It Yourself

If you want hands-on practice implementing the HITL pattern, you can build it step-by-step.

Short on time?

The complete solution is available in section-2/step-06. You can explore the code there if you prefer to move forward quickly.

Prerequisites

Before starting:

  • Completed Step 05 (or have the section-2/step-05/multi-agent-system code available)
  • Application from Step 05 is stopped (Ctrl+C)

Implementation Steps

  1. Copy the step-05 code to create step-06 base
  2. Create DispositionProposalAgent.java with proposal generation logic
  3. Create HumanApprovalAgent.java using @HumanInTheLoop annotation with blocking approval logic
  4. Create ApprovalService.java to manage CompletableFuture instances for pausing/resuming workflows
  5. Create ApprovalProposal.java entity for persisting proposals
  6. Create ApprovalResource.java REST endpoints for the UI
  7. Update FleetSupervisorAgent.java to add value-based routing
  8. Update CarConditions.java to add disposition status fields
  9. Update application.properties with approval threshold
  10. Test with different vehicle values

Follow the code examples shown earlier in this guide.


Experiment Further

1. Adjust the Approval Threshold

Try different threshold values to see how they affect which vehicles require approval:

  • Lower the threshold to $10,000 to require approval for more vehicles
  • Raise it to $25,000 to only catch the most expensive ones
  • Set it to $0 to require approval for all dispositions

2. Add Approval Workflows

Implement multi-level approval:

  • \(15,000-\)25,000: Single approver
  • \(25,000-\)50,000: Two approvers
  • $50,000: Manager approval required

3. Track Approval Metrics

Add monitoring:

  • Approval rate by value range
  • Average approval time
  • Rejection reasons analysis
  • Approver performance metrics

4. Implement Approval Timeouts

Add time limits:

  • Auto-reject after 24 hours
  • Escalate to manager after 48 hours
  • Send reminder notifications

5. Add Approval History

Track all approvals:

  • Who approved/rejected
  • When the decision was made
  • Reasoning provided
  • Outcome of the decision

Troubleshooting

All vehicles going through approval workflow

Check that the value threshold is correctly configured in application.properties and that the PricingAgent is returning numeric values that can be compared.

Workflow not pausing for human approval

Verify that:

  • The HumanApprovalAgent has the @HumanInTheLoop annotation
  • The ApprovalService is correctly creating the CompletableFuture
  • The agent method is calling .get() on the future to block
Approval status not being tracked

Verify that:

  • FleetSupervisorAgent stores disposition status in AgenticScope
  • CarConditions model has the dispositionStatus and dispositionReason fields
  • CarProcessingWorkflow retrieves these values from the scope
Low-value vehicles still requiring approval

Check the value comparison logic in FleetSupervisorAgent. Ensure the PricingAgent output is being parsed correctly as a number.

Timeout errors when waiting for approval

The HumanApprovalAgent has a 5-minute timeout by default. If you need more time, adjust the timeout value in the .get(5, TimeUnit.MINUTES) call. On timeout, the system defaults to REJECTED for safety.


Agent Observability with MonitoredAgent

Beyond the HITL workflow, step-06 also introduces agent observability - the ability to inspect what every agent in the system did, what inputs it received, what it produced, and how long it took.

LangChain4j provides the MonitoredAgent interface and an HtmlReportGenerator utility in the dev.langchain4j.agentic.observability package. Together, they give you a full execution report of your agentic system with zero manual instrumentation.

Monitoring Agentic System Execution

MonitoredAgent is a simple interface with a single method:

public interface MonitoredAgent {
    AgentMonitor agentMonitor();
}

When your top-level workflow interface extends MonitoredAgent, LangChain4j automatically attaches an AgentMonitor listener to the entire agent tree. The AgentMonitor implements AgentListener and records every agent invocation across the system:

  • Before each invocation: captures the agent name, inputs, and start time
  • After each invocation: captures the output and finish time
  • On errors: captures the exception details
  • Nested invocations: tracks the full call hierarchy (e.g., FleetSupervisorAgent calling PricingAgent calling DispositionProposalAgent)

The monitor groups executions by memory ID, so you can inspect each independent workflow run separately. It tracks ongoing, successful, and failed executions.

To enable this feature, it is enough to do the following:

1. Extend MonitoredAgent in the workflow interface

In CarProcessingWorkflow.java, the interface simply extends MonitoredAgent:

public interface CarProcessingWorkflow extends MonitoredAgent {

    @SequenceAgent(outputKey = "carProcessingAgentResult",
            subAgents = { FeedbackWorkflow.class, FleetSupervisorAgent.class,
                          CarConditionFeedbackAgent.class })
    CarConditions processCarReturn(/* ... */);
}

That’s it - no annotations on individual agents, no manual tracking code. The framework handles everything.

2. Generate an HTML report from the monitor

In CarManagementService.java, the report() method uses the static HtmlReportGenerator.generateReport() helper:

import static dev.langchain4j.agentic.observability.HtmlReportGenerator.generateReport;

public String report() {
    return generateReport(carProcessingWorkflow.agentMonitor());
}

This produces a self-contained HTML page with:

  • Agent topology: a visual map of all agents and their relationships (sequential, parallel, supervisor, etc.), including the data flow keys that connect them
  • Execution timeline: for each workflow run, a detailed breakdown showing every agent invocation with inputs, outputs, duration, and nesting level
  • Error tracking: any failed invocations are highlighted with their exception details

Viewing the Report

The report is exposed via a REST endpoint in CarManagementResource.java:

@GET
@Path("/report")
@Produces(MediaType.TEXT_HTML)
public Response report() {
    return Response.ok(carManagementService.report()).build();
}

After processing one or more cars, click the “Generate Report” button in the UI (next to “Refresh Data”) to open the report in a new tab. The report shows:

  1. The full agent topology of your system
  2. Every execution grouped by workflow run
  3. For each agent invocation: what went in, what came out, and how long it took

This is invaluable for debugging agent behavior, understanding why the supervisor made a particular routing decision, or verifying that the HITL workflow paused and resumed correctly.


What’s Next?

Congratulations! You’ve completed the final step of Section 2 and implemented the Human-in-the-Loop pattern for safe, controlled autonomous decision-making!

The system now:

  • Routes high-value vehicles through human approval using LangChain4j’s @HumanInTheLoop annotation
  • Creates proposals for human review via the DispositionProposalAgent
  • Pauses workflow execution in the HumanApprovalAgent until a human decides
  • Tracks approval decisions for audit trails
  • Provides fallback paths for rejected proposals
  • Balances automation with human oversight

Ready to wrap up? Head to the conclusion to review everything you’ve learned and see how these patterns apply to real-world scenarios!

Continue to Conclusion - Mastering Agentic Systems