Human-in-the-Loop (HITL) Design
Level: Intermediate Pre-reading: 06 · LangGraph Agent · 08 · Chat Engine
This document defines every interrupt trigger, the approval/rejection flow, the feedback loop for revisions, and provides full conversation transcript mockups for both demo tickets.
HITL Principles
| Principle | Implementation |
|---|---|
| Agent proposes, human approves | Every code change requires explicit approve before any git operation |
| No stealth automation | Every agent action is surfaced as a chat message |
| Reversible gates | reject cancels the run; feedback loops back for revision |
| Audit trail | Every approval/rejection stored in PostgreSQL checkpoint |
| Max 3 revision rounds | Infinite loops are hard-capped at iteration 3 → escalate to human |
Interrupt Trigger Conditions
The agent pauses and waits for human input at these points:
graph TD
A["Agent generates code changes"] --> B{Scope check}
B -->|"> 300 lines changed"| HG1["⚠️ INTERRUPT: Large diff warning"]
B -->|"> 2 modules touched"| HG2["⚠️ INTERRUPT: Multi-module scope warning"]
B -->|Normal scope| C["Prepare diff summary"]
HG1 --> C
HG2 --> C
C --> HG3["⏸ INTERRUPT: Standard approval gate<br/>(always fires before any git write)"]
HG3 -->|"approve"| D["Apply changes → Create PR"]
HG3 -->|"reject"| E["Abort → notify user"]
HG3 -->|"feedback text"| F["Regenerate with feedback"]
F -->|"iteration < 3"| A
F -->|"iteration >= 3"| E
Interrupt Message Formats
Standard Approval Gate
═══════════════════════════════════════════════════════════
📋 TASK-101 — Fix NullPointerException in TaskService
Root Cause: TaskService.getSummary() calls task.getAssignee().toUpperCase()
without checking if assignee is null. When a Task has no assignee,
this throws a NullPointerException.
Files to be changed:
• taskmaster-core/src/main/java/com/demo/taskmaster/core/service/TaskService.java
→ Added null-check: returns "Unassigned" when assignee is null
• taskmaster-core/src/test/java/com/demo/taskmaster/core/service/TaskServiceTest.java
→ Added test: getSummary_whenAssigneeIsNull_returnsUnassigned()
Total: 2 files, ~15 lines changed
Modules affected: taskmaster-core
──────────────────────────────────────────────────────────
Do you approve these changes?
Type: approve | reject | [your feedback for revision]
═══════════════════════════════════════════════════════════
Large Diff Warning
═══════════════════════════════════════════════════════════
⚠️ LARGE DIFF DETECTED — Please review carefully
TASK-102 — Add dueDate field to Task entity
Files to be changed (6 files, ~180 lines):
• taskmaster-core/src/.../Task.java (entity field)
• taskmaster-core/src/.../TaskService.java (business logic)
• taskmaster-api/src/.../TaskRequest.java (DTO field)
• taskmaster-api/src/.../TaskResponse.java (DTO field)
• taskmaster-api/src/.../TaskController.java (mapping)
• taskmaster-e2e/tests/task-create.spec.ts (new E2E test)
Modules affected: taskmaster-core, taskmaster-api, taskmaster-e2e
This is a cross-module change. All acceptance criteria have been mapped:
✅ AC1: dueDate field on Task entity (LocalDate)
✅ AC2: POST /api/tasks accepts dueDate
✅ AC3: GET /api/tasks returns dueDate
✅ AC4: dueDate is optional — null for existing tasks
✅ AC5: Playwright E2E test for dueDate round-trip
Type: approve | reject | [feedback]
═══════════════════════════════════════════════════════════
Full Conversation Transcript: TASK-101 (Bug Fix)
USER: TASK-101
AGENT: 🤖 Starting agent for TASK-101...
▶ Running: fetch_ticket
✅ Fetched ticket TASK-101: "Fix NullPointerException in TaskService when task has no assignee"
▶ Running: classify_ticket
📋 Classified as Bug — complexity: low
▶ Running: identify_modules
🎯 Modules affected: taskmaster-core
Reasoning: The NullPointerException is in TaskService.getSummary() which lives
in taskmaster-core. No API contract changes are needed — the fix is internal
to the service layer.
▶ Running: retrieve_context
🔍 Retrieved 5 relevant code chunks:
- taskmaster-core/src/.../TaskService.java (similarity: 0.94)
- taskmaster-core/src/.../Task.java (similarity: 0.88)
- taskmaster-core/src/.../TaskServiceTest.java (similarity: 0.82)
- taskmaster-core/src/.../TaskRepository.java (similarity: 0.71)
- taskmaster-api/src/.../TaskController.java (similarity: 0.63)
▶ Running: generate_code_changes
💡 Generated 1 file change
▶ Running: generate_tests
🧪 Generated 1 test file
▶ Running: prepare_diff_summary
═══════════════════════════════════════════════════════════
📋 TASK-101 — Fix NullPointerException in TaskService
Root Cause: TaskService.getSummary() calls task.getAssignee().toUpperCase()
directly without a null check on line 23. When assignee is null, this
throws a NullPointerException.
Files to be changed:
• taskmaster-core/src/main/java/.../service/TaskService.java
→ Replace: task.getAssignee().toUpperCase()
→ With: task.getAssignee() != null ? task.getAssignee().toUpperCase() : "Unassigned"
• taskmaster-core/src/test/java/.../service/TaskServiceTest.java
→ Added @Test: getSummary_whenAssigneeIsNull_returnsUnassigned
Total: 2 files, ~12 lines changed across 1 module.
Do you approve? (approve / reject / feedback)
═══════════════════════════════════════════════════════════
USER: approve
AGENT: ▶️ Resuming with: approve
▶ Running: apply_changes
📤 Pushed 2 file(s) to branch ai/TASK-101-fix-npe-taskservice
▶ Running: create_pull_request
🚀 Pull Request created: https://github.com/yourname/taskmaster/pull/17
▶ Running: post_jira_comment
💬 Commented on TASK-101 with PR link
═══════════════════════════════════════════════════════════
✅ DONE!
PR: https://github.com/yourname/taskmaster/pull/17
JIRA: https://yourname.atlassian.net/browse/TASK-101
CI will run automatically. Review and merge when ready.
═══════════════════════════════════════════════════════════
Total elapsed time: ~4 minutes
Full Conversation Transcript: TASK-102 (Story)
USER: TASK-102
AGENT: 🤖 Starting agent for TASK-102...
▶ Running: fetch_ticket
✅ Fetched ticket TASK-102: "Add dueDate field to Task entity and expose via REST API"
▶ Running: classify_ticket
📋 Classified as Story — complexity: medium
▶ Running: identify_modules
🎯 Modules affected: taskmaster-core, taskmaster-api, taskmaster-e2e
Reasoning: Adding a new field to the Task entity requires changes in all
three modules — the entity and service (core), the DTOs and controller (api),
and an E2E test to verify the full round-trip (e2e). AC5 explicitly requires
a Playwright test.
▶ Running: retrieve_context
🔍 Retrieved 10 relevant code chunks:
- taskmaster-core/src/.../Task.java (0.95)
- taskmaster-api/src/.../TaskRequest.java (0.91)
- taskmaster-api/src/.../TaskResponse.java (0.90)
- taskmaster-api/src/.../TaskController.java (0.87)
- taskmaster-e2e/tests/task-create.spec.ts (0.85)
+ 5 more...
▶ Running: generate_code_changes
💡 Generated 5 file changes
▶ Running: generate_tests
🧪 Generated 1 test file (Playwright E2E)
▶ Running: prepare_diff_summary
═══════════════════════════════════════════════════════════
✨ TASK-102 — Add dueDate field to Task entity and expose via REST API
Acceptance Criteria Coverage:
✅ AC1 → Task.java: added `private LocalDate dueDate;` with getter/setter
✅ AC2 → TaskRequest.java: added `dueDate` field + TaskController mapping
✅ AC3 → TaskResponse.java: `dueDate` included in GET response
✅ AC4 → dueDate is Optional — not annotated @Column(nullable=false)
✅ AC5 → task-create.spec.ts: new test verifies dueDate round-trip
Files to be changed (6 files, ~180 lines):
• taskmaster-core/src/.../model/Task.java
• taskmaster-core/src/.../service/TaskService.java
• taskmaster-api/src/.../dto/TaskRequest.java
• taskmaster-api/src/.../dto/TaskResponse.java
• taskmaster-api/src/.../controller/TaskController.java
• taskmaster-e2e/tests/task-create.spec.ts
⚠️ Multi-module change across 3 modules. Please verify scope is correct.
Do you approve? (approve / reject / feedback)
═══════════════════════════════════════════════════════════
USER: The dueDate should be validated — reject dates in the past.
AGENT: ▶️ Received feedback. Revising... (iteration 2)
▶ Running: generate_code_changes
💡 Revision: added @FutureOrPresent validation annotation to Task.dueDate
▶ Running: generate_tests
🧪 Updated tests to cover past-date rejection (400 Bad Request)
═══════════════════════════════════════════════════════════
✨ TASK-102 — Revised (iteration 2)
Changes updated based on your feedback:
• Task.java: added @FutureOrPresent on dueDate field
• TaskRequest.java: validation flows through @Valid in controller
• TaskServiceTest.java: added test for past date returning 400
• task-create.spec.ts: added test expecting 400 for past dueDate
All 5 original ACs still satisfied, plus your validation requirement.
Do you approve? (approve / reject / feedback)
═══════════════════════════════════════════════════════════
USER: approve
AGENT: ▶️ Resuming with: approve
▶ Running: apply_changes
📤 Pushed 6 file(s) to branch ai/TASK-102-add-duedate-field-to-task-entity
▶ Running: create_pull_request
🚀 Pull Request created: https://github.com/yourname/taskmaster/pull/18
▶ Running: post_jira_comment
💬 Commented on TASK-102
═══════════════════════════════════════════════════════════
✅ DONE!
PR: https://github.com/yourname/taskmaster/pull/18
JIRA: https://yourname.atlassian.net/browse/TASK-102
6 files changed · CI running · Ready for your review.
═══════════════════════════════════════════════════════════
Total elapsed time: ~9 minutes (including one revision cycle)
Rejection Flow
USER: reject
AGENT: ❌ Change rejected. No files were committed.
The branch ai/TASK-101-... was not created.
TASK-101 has been left in its current status.
Type a new ticket key to start again, or describe what you'd like to change.
Why show the full diff summary before applying, rather than after?
The diff summary is the safety gate. Showing it before any git write means the human sees exactly what will happen before it's irreversible. A PR can be closed after creation, but it's still visible in GitHub history — better to catch problems before the branch is created.
How is the feedback from the revision round incorporated into the next generation?
The user's feedback text is stored in AgentState.human_feedback. On the next pass through generate_code_changes, the prompt includes: "Previous attempt was rejected with this feedback: {human_feedback}. Incorporate this change in your revised output." The LLM receives the original context plus the critique.
What if the user's feedback would require changing out-of-scope modules?
The identify_modules node runs once and sets scope. If feedback implies a scope expansion (e.g., "also update the database migration"), the agent flags this: "Your feedback requires changes in additional modules. Please confirm expanded scope: [modules]." This prevents uncontrolled scope creep in revision rounds.