Human-in-the-Loop
|
This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
This page’s examples target Python 3.12 with LangChain 1.4.2 / langchain-core 1.6.5 / LangGraph 1.2.12 and
langchain-ollama 1.1.0, Java 21 with LangChain4j 1.20.2 (langchain4j-agentic 1.20.2-beta30), and Spring Boot
4.1 with Spring AI 2.0.1, using DocsAssistant's Ollama llama3.1:8b chat model, as checked on 2026-09-28 (see
the AI Agents baseline).
An agent that only reads can be wrong; an agent that writes can be wrong in the world. Human-in-the-loop (HITL) design decides where a person must look before the agent proceeds, what they can do when they look, and how the agent waits for them without losing its place. This page pays off the forward reference from Tool Design: DocsAssistant’s one write action, opening a GitHub issue when a page is wrong, never runs without a human’s approval.
Approval gates for write actions
An approval gate pauses the agent immediately before a consequential action, shows a reviewer exactly what will happen — the tool, the arguments, the agent’s reasons — and lets them decide:
-
approve — run the action as proposed;
-
edit — change the arguments (fix the issue title, drop a label) and run the edited action;
-
reject — do not run it, optionally telling the agent why so it can adjust;
-
respond — do not run the tool, and answer on its behalf (the reviewer supplies the result).
Three properties make a gate trustworthy. It is enforced by the runtime, not requested in the prompt, so no model output can skip it. It shows the actual call that will execute, not the model’s description of it. And it is durable: the agent’s state is checkpointed at the pause, so the reviewer can answer minutes or days later, from a different process, and the run resumes exactly where it stopped.
Interrupt and resume in LangChain and LangGraph
HumanInTheLoopMiddleware
For a create_agent agent, the gate is one middleware: interrupt_on maps each tool name to either False (never
interrupt) or the decisions a reviewer may take. The agent needs a checkpointer, because an interrupt is a saved
state that a later call resumes.
from langchain.agents import create_agent
from langchain.agents.middleware import HumanInTheLoopMiddleware
from langchain_ollama import ChatOllama
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import Command
agent = create_agent(
ChatOllama(model="llama3.1:8b", temperature=0),
tools=[docs_search, github_open_issue],
middleware=[HumanInTheLoopMiddleware(interrupt_on={
"github_open_issue": {"allowed_decisions": ["approve", "edit", "reject"]},
"docs_search": False,
})],
checkpointer=InMemorySaver(),
)
config = {"configurable": {"thread_id": "report-42"}}
result = agent.invoke(
{"messages": [("user", "The memory page says MessageWindowChatMemory keeps 10 messages.")]},
config,
)
request = result["__interrupt__"][0].value # {"action_requests": [...], "review_configs": [...]}
# ...later, when the reviewer has decided (same thread_id):
agent.invoke(Command(resume={"decisions": [{"type": "approve"}]}), config)
When the model asks for github_open_issue, the run stops before the tool executes and returns an interrupt
entry. Its value lists each pending call and what the reviewer may do with it — this is the actual payload, as
produced by the example:
{
"action_requests": [{
"name": "github_open_issue",
"args": {
"page_path": "modules/ROOT/pages/ai/agents/memory.adoc",
"problem": "States the wrong default window size for MessageWindowChatMemory.",
"evidence": "https://docs.spring.io/spring-ai/reference/api/chat-memory.html"
},
"description": "Tool execution requires approval\n\nTool: github_open_issue\nArgs: {...}"
}],
"review_configs": [{
"action_name": "github_open_issue",
"allowed_decisions": ["approve", "edit", "reject"]
}]
}
The resume payload carries one decision per pending call, in order: {"type": "approve"},
{"type": "edit", "edited_action": {"name": …, "args": {…}}}, {"type": "reject", "message": "…"} or
{"type": "respond", "message": "…"}. In production, the InMemorySaver becomes a durable checkpointer
(see Memory) so the pause survives restarts. See
LangChain — Human-in-the-loop.
LangGraph interrupt
In a hand-built graph, interrupt(value) pauses inside any node: value is handed to the caller, and the node
resumes — re-running from its start — with whatever the caller passes as Command(resume=…). This version also
routes by the model’s confidence, which the middleware cannot do on its own:
from typing import Literal, TypedDict
from langchain_ollama import ChatOllama
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import END, START, StateGraph
from langgraph.types import Command, interrupt
from pydantic import BaseModel
llm = ChatOllama(model="llama3.1:8b", temperature=0)
ESCALATE_BELOW = 0.5 # too unsure to even propose an issue: a human investigates instead
class Assessment(BaseModel):
page_is_wrong: bool
confidence: float # 0.0-1.0, the model's own estimate
problem: str
evidence: str
class ReportState(TypedDict):
page_path: str
user_report: str
assessment: dict # plain data, so the checkpointer can serialise it
outcome: str
def assess(state: ReportState) -> dict:
a = llm.with_structured_output(Assessment).invoke(
f"User says {state['page_path']} is wrong: {state['user_report']}. Check the official "
"sources; say whether the page is wrong, how confident you are (0-1), and the evidence.")
return {"assessment": a.model_dump()}
def route(state: ReportState) -> Literal["approval", "escalate", "__end__"]:
a = state["assessment"]
if not a["page_is_wrong"]:
return END
return "approval" if a["confidence"] >= ESCALATE_BELOW else "escalate"
def approval(state: ReportState) -> Command[Literal["file_issue", "__end__"]]:
a = state["assessment"]
decision = interrupt({ # pauses here; the value goes to the caller
"question": "Open this GitHub issue?",
"page_path": state["page_path"],
**a,
})
if decision["type"] == "approve":
return Command(goto="file_issue")
if decision["type"] == "edit":
return Command(goto="file_issue", update={"assessment": {**a, "problem": decision["problem"]}})
return Command(goto=END, update={"outcome": f"rejected: {decision.get('reason', '')}"})
def file_issue(state: ReportState) -> dict:
a = state["assessment"]
result = github_open_issue.invoke(
{"page_path": state["page_path"], "problem": a["problem"], "evidence": a["evidence"]})
return {"outcome": str(result)}
def escalate(state: ReportState) -> dict:
return {"outcome": "escalated to the docs maintainers' triage queue"}
builder = StateGraph(ReportState)
builder.add_node("assess", assess)
builder.add_node("approval", approval)
builder.add_node("file_issue", file_issue)
builder.add_node("escalate", escalate)
builder.add_edge(START, "assess")
builder.add_conditional_edges("assess", route)
builder.add_edge("file_issue", END)
builder.add_edge("escalate", END)
report_flow = builder.compile(checkpointer=InMemorySaver())
config = {"configurable": {"thread_id": "report-42"}}
paused = report_flow.invoke(
{"page_path": "modules/ROOT/pages/ai/agents/memory.adoc",
"user_report": "It says MessageWindowChatMemory keeps 10 messages by default."},
config,
)
review_request = paused["__interrupt__"][0].value # show this to the reviewer
finished = report_flow.invoke(Command(resume={"type": "approve"}), config)
Because the node re-runs from the beginning on resume, code before interrupt() must be safe to repeat — here it
only reads state; never call the write tool before the interrupt. The assessment is stored as a plain dict
(model_dump()), which the checkpointer can serialise without registering custom types. See
LangGraph — Persistence and
LangChain — Human-in-the-loop.
LangChain4j and Spring AI
Neither Java framework has a durable, checkpointed interrupt for tool calls, so approval uses one of two patterns.
Ask the human in the flow. LangChain4j’s agentic module has a built-in HumanInTheLoop agent: a non-AI agent
whose responseProvider asks a person and writes the answer into the AgenticScope. Marked async(true), it
lets agents that do not need the answer keep running while it waits.
import dev.langchain4j.agentic.AgenticServices;
import dev.langchain4j.agentic.UntypedAgent;
import dev.langchain4j.agentic.workflow.HumanInTheLoop;
HumanInTheLoop approval = AgenticServices.humanInTheLoopBuilder()
.description("Asks a maintainer to approve the drafted issue")
.outputKey("approval")
.async(true)
.responseProvider(scope -> approvalInbox.askAndWait( // your UI / chat / queue
(String) scope.readState("issueDraft")))
.build();
UntypedAgent reportFlow = AgenticServices.sequenceBuilder()
.subAgents(issueDrafter, approval, issueFiler) // issueFiler checks "approval" first
.outputKey("result")
.build();
See LangChain4j — Agents and Agentic AI (human-in-the-loop). This holds a thread (or a future) for as long as the human takes, so it suits minutes, not days.
Persist a pending action. For approvals that may take days, the tool the model calls does not perform the write at all: it records a pending action and returns its ID; a separate, authenticated endpoint — not reachable by the model — executes it when a human approves. This works identically in LangChain4j and Spring AI.
public class IssueProposalTools {
private final PendingActionRepository pending;
public IssueProposalTools(PendingActionRepository pending) {
this.pending = pending;
}
@Tool(name = "github_propose_issue", description = """
Propose a GitHub issue for a wrong documentation page. It is NOT filed until a
maintainer approves it; tell the user it is awaiting review.""")
public String propose(String pagePath, String problem, String evidence, ToolContext ctx) {
PendingAction action = pending.save(PendingAction.issue(
pagePath, problem, evidence, (String) ctx.getContext().get("reporter")));
return "Proposed as pending action " + action.id() + "; awaiting maintainer approval.";
}
}
@RestController
class ApprovalController { // secured: maintainers only
private final PendingActionRepository pending;
private final GitHubTools github; // the least-privilege write tool
ApprovalController(PendingActionRepository pending, GitHubTools github) {
this.pending = pending;
this.github = github;
}
@PostMapping("/approvals/{id}/approve")
String approve(@PathVariable UUID id) {
PendingAction a = pending.claim(id); // PENDING -> APPROVED, exactly once
return github.openIssue(a.pagePath(), a.problem(), a.evidence(),
new ToolContext(Map.of("reporter", a.reporter())));
}
}
The pending record is also the audit trail (who proposed, who approved, when), and claim makes approval
idempotent. Spring AI 2.0 also lets you take over the tool loop per call — disable the auto-registered tool-calling
advisor with AdvisorParams.toolCallingAdvisorAutoRegister(false) and execute each round yourself with
ToolCallingManager.executeToolCalls(…) — which is the documented way to gate tool execution on an in-process
approval step. For asking the user a clarifying question mid-task, the community spring-ai-agent-utils library
provides AskUserQuestionTool. See Spring AI — Tool
calling (User-Controlled Tool Execution) and
spring-ai-agent-utils.
Escalation thresholds and uncertainty routing
Approval gates are about consequence; escalation is about uncertainty. The idea is to route each decision by how sure the agent is: act when confident, ask when unsure, hand over when lost. Useful uncertainty signals include a self-reported confidence (cheap, but models are often over-confident — calibrate it against labelled cases), the agreement between several samples (self-consistency: no majority means escalate), retrieval scores below a floor, a critic’s rejection, or simply a policy category ("anything touching billing goes to a human").
Design the thresholds explicitly and per action, and measure them:
-
set them from data — plot the agent’s accuracy against its confidence on an evaluation set and choose the point where acting autonomously meets the error rate you can accept (see Agent Evaluation);
-
make escalation cheap and graceful for the user ("I’ve passed this to a maintainer; you’ll get a link") so the agent is not tempted, by its prompt, to avoid it;
-
log every escalation with its reason — the escalation rate is one of the most useful health metrics an agent has, and it is the input for improving the agent later (see Learning & Improvement).
The autonomy slider
The same agent can run at different levels of autonomy, and the level is best set per action rather than per agent:
| Level | What the agent does | DocsAssistant example |
|---|---|---|
Manual |
Nothing on its own; a human performs the action, perhaps from the agent’s draft |
A maintainer files the issue themselves from DocsAssistant’s summary |
Suggest |
Proposes the action; a human approves, edits or rejects it |
DocsAssistant drafts the issue; a maintainer clicks approve |
Act |
Performs the action and reports afterwards |
DocsAssistant searches the docs without asking |
Autonomy is earned: start write actions at suggest, measure how often reviewers approve unchanged, and move an action towards act only when that rate, and the cost of the rare mistake, justify it. Moving back must be just as easy — a configuration change, not a redeploy. With the middleware above, the slider is literally a mapping:
from langchain.agents.middleware import HumanInTheLoopMiddleware
# The autonomy slider, per tool: "manual" (a human does it), "suggest" (the agent proposes,
# a human approves or edits), "act" (the agent acts on its own).
AUTONOMY = {
"docs_search": "act",
"docs_get_section": "act",
"github_open_issue": "suggest",
}
DECISIONS = {
"manual": {"allowed_decisions": ["respond", "reject"]}, # a human supplies the result
"suggest": {"allowed_decisions": ["approve", "edit", "reject"]},
"act": False, # no interrupt
}
def hitl_for(autonomy: dict[str, str]) -> HumanInTheLoopMiddleware:
return HumanInTheLoopMiddleware(interrupt_on={t: DECISIONS[level] for t, level in autonomy.items()})
"respond" lets the human supply the tool result instead of running the tool, which is exactly manual. See
LangChain — Human-in-the-loop.
In other frameworks
| Framework | Human-in-the-loop support |
|---|---|
OpenAI Agents SDK |
|
Claude Agent SDK |
The |
Microsoft Agent Framework |
|
CrewAI |
|