Appearance
6.3 Human-in-the-Loop (HITL) Patterns & The Functional API β
Canonical Curriculum Reference:
docs/plan/06_phase6_stateful_graphs_hitl.md
Textbook Reference: AI Agents and Applications (Manning Publications), Chapter 14, Section 14.3.2 ("Human-in-the-loop", pp. 374β375) & Section 14.1.6 ("Rewinding state", pp. 362β365).
π 1. Human-in-the-Loop (HITL): The Need for Circuit Breakers β
In mission-critical enterprise systems, fully autonomous agents present unacceptable liability risks:
- An automated customer support agent must not issue a $10,000 refund without manager sign-off.
- A DevOps remediation agent must not execute
DROP TABLEorkubectl delete namespacewithout human approval. - An automated content generator must not publish marketing copy to live social feeds without editorial review.
Traditional web applications handle human intervention by breaking the backend into disconnected endpoints. In LangGraph 1.0, Human-in-the-Loop is a native architectural primitive integrated directly into the stateful execution engine:
[ Automated Node: Draft Proposal ]
|
v
+-------------------+
| interrupt() | <--- Execution halts! State committed to Checkpointer.
+-------------------+
|
(Awaiting Human) <--- User reviews proposal via Web UI / CLI.
|
+-------------------+
| Command(resume) | <--- Human approves or provides edits.
+-------------------+
|
v
[ Automated Node: Execute Final Action ]βΈοΈ 2. The interrupt() and Command(resume=...) Primitives β
In LangGraph 1.0, human intervention is governed by two complementary primitives:
interrupt(value): Halts graph execution immediately at the current node. It emitsvalue(the payload needing review) to the client and persists the exact execution state into the configured checkpointer. The process exits cleanly, releasing worker threads.Command(resume=value): When the human provides their review decision, the client re-invokes the graph withCommand(resume=decision). LangGraph rehydrates the state from the checkpointer and resumes execution right where it was paused, withinterrupt()returning the human's input directly.
Production Pattern: The Stateful Approval Gate β
python
from typing import TypedDict
from langgraph.checkpoint.memory import MemorySaver
from langgraph.graph import StateGraph, START, END
from langgraph.types import interrupt, Command
class RefundRequestState(TypedDict):
customer_id: str
amount_usd: float
reason: str
status: str
supervisor_notes: str | None
def draft_refund_node(state: RefundRequestState) -> dict:
"""Automated initial intake and policy evaluation."""
return {"status": "AWAITING_HUMAN_APPROVAL"}
def approval_gate_node(state: RefundRequestState) -> dict:
"""Interrupts execution for human financial sign-off."""
# 1. Pause execution and surface payload to human
human_decision: dict = interrupt({
"action": "AUTHORIZE_REFUND",
"customer_id": state["customer_id"],
"amount_usd": state["amount_usd"],
"reason": state["reason"]
})
# 2. Resumed with Command(resume={"approved": True/False, "notes": "..."})
if human_decision.get("approved"):
return {
"status": "APPROVED",
"supervisor_notes": human_decision.get("notes", "Approved by human supervisor.")
}
else:
return {
"status": "REJECTED",
"supervisor_notes": human_decision.get("notes", "Rejected by human supervisor.")
}
builder = StateGraph(RefundRequestState)
builder.add_node("draft_refund", draft_refund_node)
builder.add_node("approval_gate", approval_gate_node)
builder.add_edge(START, "draft_refund")
builder.add_edge("draft_refund", "approval_gate")
builder.add_edge("approval_gate", END)
checkpointer = MemorySaver()
refund_app = builder.compile(checkpointer=checkpointer)β‘ 3. The LangGraph Functional API: @entrypoint & @task β
For developers who find explicit graph builders (StateGraph, add_node, add_edge) overly verbose for procedural workflows, LangGraph introduces the Functional API.
Using @entrypoint and @task decorators, you can build durable, interruptible, stateful workflows using standard, idiomatic Python functions:
python
from langgraph.func import entrypoint, task
from langgraph.types import interrupt
@task
def step_fetch_metrics(service_id: str) -> dict:
"""Atomic task with automatic caching and retry durability."""
# Deterministic metric retrieval
return {"latency_p99": 450, "error_rate": 0.08}
@task
def step_restart_pod(service_id: str) -> str:
"""Executes destructive restart action."""
return f"Pod {service_id} successfully restarted."
@entrypoint(checkpointer=MemorySaver())
def remediation_workflow(service_id: str) -> str:
"""Durable procedural workflow with native human interrupt."""
# 1. Execute task
metrics = step_fetch_metrics(service_id).result()
# 2. Interrupt for human authorization if metrics exceed thresholds
if metrics["error_rate"] > 0.05:
approval = interrupt(f"Service {service_id} has high error rate ({metrics['error_rate']}). Restart?")
if not approval:
return f"Remediation aborted by human for service {service_id}."
# 3. Proceed with action
return step_restart_pod(service_id).result()Architectural Comparison: Graph Builder vs. Functional API β
| Dimension | Graph Builder (StateGraph) | Functional API (@entrypoint / @task) |
|---|---|---|
| Mental Model | Directed cyclic state machine | Standard imperative Python function |
| Topology | Explicit nodes, edges, conditional routers | Sequential calls, standard if/for loops |
| Persistence | Commits state at node superstep boundaries | Commits state at @task and interrupt() boundaries |
| Best For | Complex multi-agent topologies (Supervisors) | Procedural workflows, data pipelines, HITL forms |
π§ͺ 4. Automated Verification with Pytest β
python
import pytest
from langgraph.types import Command
def test_human_in_the_loop_interrupt_and_resume():
config = {"configurable": {"thread_id": "thread-refund-999"}}
initial_input = {
"customer_id": "cust-502",
"amount_usd": 1500.0,
"reason": "Double billing error on enterprise invoice.",
"status": "NEW",
"supervisor_notes": None
}
# 1. Run workflow: It must halt at the approval_gate interrupt
result_stream = list(refund_app.stream(initial_input, config=config))
# Verify the workflow paused
state = refund_app.get_state(config)
assert len(state.tasks) > 0, "Expected a pending interrupt task"
assert state.tasks[0].interrupts[0].value["action"] == "AUTHORIZE_REFUND"
assert state.tasks[0].interrupts[0].value["amount_usd"] == 1500.0
# 2. Human supervisor provides review decision via Command(resume=...)
resume_command = Command(resume={"approved": True, "notes": "Verified against Stripe ledger."})
refund_app.invoke(resume_command, config=config)
# Verify the workflow completed successfully
final_state = refund_app.get_state(config)
assert final_state.values["status"] == "APPROVED"
assert "Verified against Stripe ledger." in final_state.values["supervisor_notes"]