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 that simulates human decision-making
- 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
Why Use Human-in-the-Loop?
HITL is essential when:
- High-stakes decisions: Financial impact, safety concerns, legal implications
- Regulatory compliance: Certain industries require human oversight
- Trust building: Gradual transition from manual to autonomous processes
- Edge cases: Unusual situations that AI might handle incorrectly
- Accountability: Clear human responsibility for critical decisions
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
Key Points:
- Proposal Phase: AI analyzes and creates a recommendation
- Approval Gate: Human reviews and decides
- Execution Phase: System acts only if approved
- Fallback: Alternative path if rejected
What is Being Added?
We’re enhancing our car management system with:
- DispositionProposalAgent: Creates disposition proposals for review
- HumanApprovalAgent: Simulates human approval decisions (in production, this would integrate with a real approval system)
- 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/>Review & Decide]
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: SCRAP/SELL/DONATE/KEEP
2. Reasoning: Clear explanation of your recommendation
Format your response as:
Proposed Action: [SCRAP/SELL/DONATE/KEEP]
Reasoning: [Your detailed explanation]
""")
@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,
Long carNumber,
String carCondition,
String carValue,
String rentalFeedback);
}
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 TRUE Human-in-the-Loop by using a tool that actually 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.agentic.tools.HumanApprovalTool;
import dev.langchain4j.agentic.Agent;
import dev.langchain4j.service.SystemMessage;
import dev.langchain4j.service.UserMessage;
import io.quarkiverse.langchain4j.ToolBox;
/**
* TRUE Human-in-the-Loop Agent that uses a tool to pause workflow execution
* and wait for actual human approval through the UI.
*
* This agent has access to the requestHumanApproval tool which BLOCKS execution
* until a human makes a decision via the REST API and UI.
*/
public interface HumanApprovalAgent {
@SystemMessage("""
You are a human approval coordinator for high-value vehicle dispositions.
Your role is to request human approval for disposition proposals by using the requestHumanApproval tool.
IMPORTANT: You MUST call the requestHumanApproval tool with ALL the provided information.
The tool will pause the workflow and wait for a human to approve or reject the proposal through the UI.
After calling the tool, you will receive the human's decision. Format your response as:
Decision: [APPROVED or REJECTED]
Reason: [The human's reasoning]
Do not make decisions yourself - always use the tool to get human input.
""")
@UserMessage("""
A disposition proposal needs human approval:
Vehicle: {carYear} {carMake} {carModel} (#{carNumber})
Estimated Value: {carValue}
Current Condition: {carCondition}
Damage Report: {rentalFeedback}
Proposed Action: {proposedDisposition}
Reasoning: {dispositionReason}
Use the requestHumanApproval tool to get human approval for this proposal.
Pass ALL the information to the tool.
""")
@Agent(outputKey = "approvalDecision", description = "Coordinates human approval for high-value vehicle dispositions using the requestHumanApproval tool")
@ToolBox(HumanApprovalTool.class)
String reviewDispositionProposal(
String carMake,
String carModel,
Integer carYear,
Long carNumber,
String carValue,
String proposedDisposition,
String dispositionReason,
String carCondition,
String rentalFeedback);
}
Key Points:
- Uses the
requestHumanApprovaltool which BLOCKS execution until human decides - The tool calls
HumanInputService.requestInput()which returns aCompletableFuture - Workflow execution pauses by calling
.get()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
Create the HumanApprovalTool
This tool implements the actual blocking mechanism for TRUE HITL.
Create src/main/java/com/carmanagement/agentic/tools/HumanApprovalTool.java:
package com.carmanagement.agentic.tools;
import com.carmanagement.model.ApprovalProposal;
import com.carmanagement.service.ApprovalService;
import dev.langchain4j.agent.tool.Tool;
import io.quarkus.logging.Log;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
/**
* Tool that enables TRUE Human-in-the-Loop by blocking workflow execution
* until a human makes an approval decision through the UI.
*/
@ApplicationScoped
public class HumanApprovalTool {
@Inject
ApprovalService approvalService;
@Tool("Request human approval for a high-value vehicle disposition proposal. This will PAUSE the workflow until a human approves or rejects the proposal via the UI.")
public String requestHumanApproval(
Long carNumber,
String carMake,
String carModel,
Integer carYear,
String carValue,
String proposedDisposition,
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");
try {
// Create proposal and get CompletableFuture that completes when human decides
CompletableFuture<ApprovalProposal> approvalFuture =
approvalService.createProposalAndWaitForDecision(
carNumber, carMake, carModel, carYear, carValue,
proposedDisposition, 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:
- Creates an approval proposal in the database
- Returns a
CompletableFuture<ApprovalProposal>that completes when human decides - Blocks by calling
.get(5, TimeUnit.MINUTES)- this is where the workflow pauses! - Workflow thread waits here until human clicks Approve/Reject in UI
- Includes 5-minute timeout for safety
- Returns human’s decision to the agent
Create the ApprovalService
This service manages the CompletableFutures that pause workflow execution.
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<Long, 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(
Long 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(
Long 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(Long 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(Long proposalId) {
return ApprovalProposal.findById(proposalId);
}
/**
* Check if there's a pending approval for a car.
*/
public ApprovalProposal getPendingProposalForCar(Long 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 Long 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(Long 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") Long 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") Long 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") Long 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();
}
}
}
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
TRUE Human-in-the-Loop Implementation
This is a TRUE HITL implementation where:
- ✅ Workflow execution actually pauses when approval is needed
- ✅ The workflow thread blocks on
CompletableFuture.get() - ✅ Human sees pending approvals in the real-time UI
- ✅ Human clicks Approve/Reject buttons in the UI
- ✅ The future completes with the human’s decision
- ✅ Workflow resumes and continues with the decision
- ✅ Includes timeout handling (5 minutes)
- ✅ Full audit trail in the database
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;
import dev.langchain4j.service.SystemMessage;
/**
* Supervisor agent that orchestrates the entire car processing workflow.
* Coordinates feedback analysis agents and action agents based on car condition.
* Implements TRUE human-in-the-loop pattern for high-value vehicle dispositions.
*/
public interface FleetSupervisorAgent {
@SystemMessage("""
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")
═══════════════════════════════════════════════════════════════════════════
CRITICAL RULE #1: CHECK dispositionRequest FIRST - DO NOT MAKE YOUR OWN DECISION
═══════════════════════════════════════════════════════════════════════════
IF dispositionRequest contains "DISPOSITION_NOT_REQUIRED":
→ The car does NOT need disposition
→ DO NOT invoke PricingAgent
→ DO NOT invoke DispositionProposalAgent
→ DO NOT invoke HumanApprovalAgent
→ DO NOT invoke DispositionAgent
→ ONLY handle cleaning/maintenance if needed
→ Include "APPROVAL_NOT_REQUIRED" in your response
→ STOP - Do not continue to disposition logic
IF dispositionRequest contains "DISPOSITION_REQUIRED":
→ Continue with disposition workflow below
═══════════════════════════════════════════════════════════════════════════
DISPOSITION WORKFLOW (ONLY if dispositionRequest = "DISPOSITION_REQUIRED")
═══════════════════════════════════════════════════════════════════════════
Step 1: Invoke PricingAgent to estimate car value
Step 2: Extract the NUMERIC value (e.g., $9,180 = 9180, $20,400 = 20400)
Step 3: Compare ONLY the numeric value to 15000
IF numeric value > 15000 (HIGH-VALUE - requires human approval):
a) Invoke DispositionProposalAgent to create proposal
b) Invoke HumanApprovalAgent (workflow will PAUSE for human decision)
c) If HumanApprovalAgent returns APPROVED:
- You MUST include the exact keyword "APPROVED_BY_USER" in your final response
- Execute the approved disposition action
- DO NOT invoke any other agents unless the action is KEEP
d) If HumanApprovalAgent returns REJECTED:
- You MUST include the exact keyword "REJECTED_BY_USER" in your final response
- The human wants to keep and repair the vehicle instead
- Invoke MaintenanceAgent to repair the vehicle
- DO NOT invoke DispositionAgent
- DO NOT try to disposition the vehicle in any other way
IF numeric value <= 15000 (LOW-VALUE - no human approval needed):
a) DO NOT invoke DispositionProposalAgent
b) DO NOT invoke HumanApprovalAgent
c) Invoke DispositionAgent DIRECTLY (it will decide: SCRAP/SELL/DONATE/KEEP)
d) You MUST include the exact keyword "APPROVAL_NOT_REQUIRED" in your final response
e) If DispositionAgent says KEEP: invoke MaintenanceAgent or CleaningAgent as needed
CRITICAL: The threshold is EXACTLY 15000. Values like 9180, 14999 are LOW-VALUE (<=15000).
Values like 15001, 16830, 20400 are HIGH-VALUE (>15000).
═══════════════════════════════════════════════════════════════════════════
CLEANING/MAINTENANCE (when disposition not required)
═══════════════════════════════════════════════════════════════════════════
- If maintenanceRequest ≠ "MAINTENANCE_NOT_REQUIRED": Invoke MaintenanceAgent
- If cleaningRequest ≠ "CLEANING_NOT_REQUIRED": Invoke CleaningAgent
- Can invoke both if both needed
- If both "NOT_REQUIRED": No action needed, car is ready
═══════════════════════════════════════════════════════════════════════════
REQUIRED KEYWORDS IN YOUR FINAL RESPONSE
═══════════════════════════════════════════════════════════════════════════
Your final response MUST include EXACTLY ONE of these keywords:
- "APPROVED_BY_USER" - if human approved disposition (high-value car, value > 15000)
- "REJECTED_BY_USER" - if human rejected disposition (high-value car, value > 15000)
- "APPROVAL_NOT_REQUIRED" - if no approval needed (low-value car value <= 15000, or no disposition)
CRITICAL: These keywords are case-sensitive and must appear EXACTLY as shown above.
The system uses these keywords to route the vehicle correctly.
""")
@SupervisorAgent(
outputKey = "supervisorDecision",
subAgents = {
PricingAgent.class,
DispositionProposalAgent.class,
HumanApprovalAgent.class,
DispositionAgent.class,
MaintenanceAgent.class,
CleaningAgent.class
}
)
String superviseCarProcessing(
String carMake,
String carModel,
Integer carYear,
Long 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,
Long carNumber,
String carCondition,
String cleaningRequest,
String maintenanceRequest,
String dispositionRequest,
String rentalFeedback
) {
// Determine if disposition is required
boolean dispositionRequired = dispositionRequest != null &&
dispositionRequest.toUpperCase().contains("DISPOSITION_REQUIRED");
if (!dispositionRequired) {
// No disposition needed - simple path
return String.format("""
═══════════════════════════════════════════════════════════════════════════
✅ NO DISPOSITION REQUIRED
═══════════════════════════════════════════════════════════════════════════
Car: %d %s %s (#%d)
Current Condition: %s
Disposition Request: %s
Cleaning Request: %s
Maintenance Request: %s
INSTRUCTIONS:
- DO NOT invoke PricingAgent
- DO NOT invoke DispositionProposalAgent
- DO NOT invoke HumanApprovalAgent
- DO NOT invoke DispositionAgent
- Only invoke MaintenanceAgent if maintenance needed
- Only invoke CleaningAgent if cleaning needed
- Include "APPROVAL_NOT_REQUIRED" in your response
""",
carYear, carMake, carModel, carNumber, carCondition,
dispositionRequest, cleaningRequest, maintenanceRequest
);
}
// Disposition required - complex path
return String.format("""
═══════════════════════════════════════════════════════════════════════════
⚠️ DISPOSITION REQUIRED - FOLLOW WORKFLOW
═══════════════════════════════════════════════════════════════════════════
Car: %d %s %s (#%d)
Current Condition: %s
Rental Feedback: %s
Disposition Request: %s
Cleaning Request: %s
Maintenance Request: %s
STEP 1: Invoke PricingAgent to get car value
STEP 2: Extract numeric value for comparison (e.g., "$10,710" → 10710)
STEP 3: Compare to threshold:
- If value > 15000: HIGH-VALUE → Invoke DispositionProposalAgent + HumanApprovalAgent
- If value <= 15000: LOW-VALUE → Invoke DispositionAgent directly (NO approval)
IMPORTANT: When invoking DispositionAgent or DispositionProposalAgent:
- Pass carValue as a STRING with dollar sign (e.g., "$10,710" not 10710)
- Use the EXACT format from PricingAgent's response
REMEMBER: You MUST include one of these keywords in your final response:
- "APPROVED_BY_USER" (if human approved)
- "REJECTED_BY_USER" (if human rejected)
- "APPROVAL_NOT_REQUIRED" (if value <= 15000, no approval needed)
""",
carYear, carMake, carModel, carNumber, carCondition, rentalFeedback,
dispositionRequest, cleaningRequest, maintenanceRequest
);
}
}
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
* @param approvalStatus Status of human approval (APPROVED/REJECTED/NOT_REQUIRED)
* @param approvalReason Reason for approval decision
*/
public record CarConditions(
String generalCondition,
CarAssignment carAssignment,
String approvalStatus,
String approvalReason
) {
/**
* Constructor for backward compatibility without approval fields.
*/
public CarConditions(String generalCondition, CarAssignment carAssignment) {
this(generalCondition, carAssignment, "NOT_REQUIRED", null);
}
}
Key Points:
- Added
approvalStatusfield (APPROVED/REJECTED/NOT_REQUIRED) - Added
approvalReasonfield 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/>Reviews Proposal]
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 reviews and likely APPROVES (severe damage justifies scrapping)
- Status:
PENDING_DISPOSITION - Condition includes approval status and reasoning
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/>Reviews Proposal]
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 REJECTS (too valuable for disposition with minor damage)
- Fallback: Routes to MaintenanceAgent instead
- Status:
IN_MAINTENANCE - Approval status:
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 (low value)
- DispositionAgent makes direct SCRAP decision
- Status:
PENDING_DISPOSITION - Approval status:
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: Reviewing proposal...
└─ Decision: APPROVED - Severe damage justifies scrapping
CarConditionFeedbackAgent updating...
└─ Approval status: APPROVED
Notice how high-value vehicles go through the proposal → approval → execution flow!
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 with approval simulation
- Update FleetSupervisorAgent.java to add value-based routing
- Update CarConditions.java to add approval fields
- Update application.properties with approval threshold
- Test with different vehicle values
Follow the code examples shown earlier in this guide.
Experiment Further
1. Implement Real Human Approval
Replace the simulated HumanApprovalAgent with a real approval system:
- Store proposals in a database with PENDING status
- Send email/Slack notifications to approvers
- Create a web UI for reviewing proposals
- Use async processing to wait for human response
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.
Approval agent always approving/rejecting
Review the approval criteria in HumanApprovalAgent’s system message. The simulated logic may need adjustment based on your test scenarios.
Approval status not being tracked
Verify that:
- FleetSupervisorAgent stores
approvalStatusandapprovalReasonin AgenticScope - CarConditions model has the new 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.
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
- Creates proposals for human review
- 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!