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:
- Value threshold: Any vehicle worth more than $15,000 requires human approval before disposition
- Two-phase workflow:
- Phase 1: AI creates a disposition proposal
- Phase 2: Human reviews and approves or rejects the proposal
- Execution control: Only execute approved dispositions
- 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
@HumanInTheLoopannotation - 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
What is Being Added?
We’re enhancing our car management system with:
- DispositionProposalAgent: Creates disposition proposals for review
- HumanApprovalAgent: Uses LangChain4j’s
@HumanInTheLoopannotation 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
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:
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:
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
@HumanInTheLoopannotation 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 aCompletableFuture - 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:
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:
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:
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 proposalsPOST /api/approvals/{id}/approve- Approve a proposalPOST /api/approvals/{id}/reject- Reject a proposal
How the HITL Flow Works End-to-End
- The
FleetSupervisorAgentdetects a high-value vehicle and invokes theDispositionProposalAgent - The proposal is passed to the
HumanApprovalAgent, which is annotated with@HumanInTheLoop - Inside the agent method,
ApprovalService.createProposalAndWaitForDecision()persists the proposal to the database and returns aCompletableFuture - The agent method blocks on
future.get(5, TimeUnit.MINUTES)— the workflow pauses here - The UI polls
GET /api/approvals/pendingand displays the proposal to the human reviewer - The human clicks Approve or Reject, which calls the corresponding REST endpoint
ApprovalService.processDecision()completes theCompletableFuturewith the decision- The agent method unblocks, formats the decision, and returns it
- 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:
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:
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
dispositionStatusfield (DISPOSITION_APPROVED/DISPOSITION_REJECTED/DISPOSITION_NOT_REQUIRED) - Added
dispositionReasonfield 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:
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
- Navigate to the step-06 directory:
- Start the application:
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:
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
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_DISPOSITIONif approved,IN_MAINTENANCEif rejected
Scenario 2: High-Value Vehicle - Approval Rejected
Enter the following text in the Mercedes Benz feedback field:
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
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_REJECTEDwith reasoning
Scenario 3: Low-Value Vehicle - No Approval Needed
Enter the following text in the Ford F-150 feedback field (Maintenance Returns tab):
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
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
- 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-systemcode available) - Application from Step 05 is stopped (Ctrl+C)
Implementation Steps
- Copy the step-05 code to create step-06 base
- Create DispositionProposalAgent.java with proposal generation logic
- Create HumanApprovalAgent.java using
@HumanInTheLoopannotation with blocking approval logic - Create ApprovalService.java to manage
CompletableFutureinstances for pausing/resuming workflows - Create ApprovalProposal.java entity for persisting proposals
- Create ApprovalResource.java REST endpoints for the UI
- Update FleetSupervisorAgent.java to add value-based routing
- Update CarConditions.java to add disposition status fields
- Update application.properties with approval threshold
- 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
HumanApprovalAgenthas the@HumanInTheLoopannotation - The
ApprovalServiceis correctly creating theCompletableFuture - 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
dispositionStatusanddispositionReasonfields - 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:
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:
- The full agent topology of your system
- Every execution grouped by workflow run
- 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
@HumanInTheLoopannotation - Creates proposals for human review via the
DispositionProposalAgent - Pauses workflow execution in the
HumanApprovalAgentuntil 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!