Human-in-the-Loop Edits Get Ignored: The Agent Runs the Original Tool Call
The approval queue is doing exactly what you built it to do. An agent proposes
send_email(to="all-customers@acme.com"), the run pauses, a human on the ops team looks at
it, notices the recipient is the entire customer list rather than the internal test address, edits the
argument, and approves. The tool runs. The edited email goes to the right place. Everyone relaxes.
Then the agent takes its next turn and proposes send_email(to="all-customers@acme.com")
again. Same tool, same original arguments, as if the human never touched it. If your reviewer is tired,
or your UI defaults to approve, or you added an auto-approve rule for actions that were "already
reviewed", that email goes out.
This is not a bug in your prompt. It is a state-threading problem in the human-in-the-loop layer, and it has a property that makes it genuinely dangerous: nothing throws. There is no error, no warning, no failed assertion. The edit appears to work, the tool result is correct, and the regression only shows up on the turn after the one you were watching.
a human edit to a proposed tool call changes the arguments of that one
call. It does not change the model's plan. On the next turn the model can re-propose the original
action, and in LangChain 1.0.x with HumanInTheLoopMiddleware it frequently does
(langchain#33787,
still open). Three things to do:
- Stop treating the middleware as an authorization boundary. Enforce the approved arguments inside the tool, against a stored approval record.
- For destructive tools, remove
editfromallowed_decisionsentirely. Allowapproveandrejectonly, and make the model re-propose. - Write a test that asserts on the arguments the tool function actually received, not on the decision payload you sent.
The symptom: the agent re-attempts the original tool call after an edit
There is no exception to grep for, which is the whole reason this survives casual testing. What you see is a transcript that looks like this:
[interrupt] send_email(to="all-customers@acme.com", subject="Q3 pricing")
[human] decision=edit, edited_action.args.to="ops-test@acme.com"
[tool] send_email -> "sent to ops-test@acme.com"
[model] I still need to email the customers about Q3 pricing.
[interrupt] send_email(to="all-customers@acme.com", subject="Q3 pricing")
The reported reproduction was on langchain==1.0.3, langchain-core==1.0.2 and
langgraph==1.0.2. The shape of the problem is not specific to those versions or to
LangChain, though. Any framework that implements approval as "pause, mutate the pending call, resume"
has the same hole, because mutating a pending call is not the same thing as revoking an intent.
Why this happens: the interrupt is scoped to a call, not to an intent
Walk through what actually happens on the graph.
- The model emits an
AIMessagecarrying one or moretool_calls. - The middleware's after-model hook sees a tool name listed in
interrupt_onand callsinterrupt(). The graph writes a checkpoint and the invocation returns to your application with the pending action. - Your UI shows it. A human sends back
Command(resume={"decisions": [{"type": "edit", "edited_action": {"name": "send_email", "args": {...}}}]}). - The graph resumes by re-running the node from the checkpoint. The middleware maps the decisions onto the pending tool calls and substitutes the edited arguments.
- The tool node executes. A
ToolMessagegoes back into the message list. Control returns to the model.
Now look at what the model sees on step 5. It sees a conversation. Somewhere in that conversation is its own stated goal, "email the customer list about Q3 pricing", and a tool result that does not obviously satisfy it. Nothing in the message history says a human deliberately narrowed this action and considers the matter closed. The graph has no such concept. The edit lived for exactly one node execution and then evaporated.
So the model does the reasonable thing: it re-plans. And its plan is the same plan it had before, because the input that produced that plan has not changed.
The second failure mode nobody mentions: decisions are matched positionally
The resume payload is a list of decisions, and the middleware processes tool calls in the
order they appear on the AIMessage. If the model emitted three tool calls and your UI sends
back two decisions, or sends them in a different order than they were rendered, you have attached a
human's approval to a different action than the one they looked at.
This is the same class of mistake as reading a webhook body before verifying its signature: the check
happens against something other than the thing that will actually be used. If you build your own
approval front end, carry the tool_call_id through the interrupt and back through the
resume, and refuse to resume if the ids do not line up. The same discipline that makes
webhook verification correct applies here
almost word for word.
The fix
Step 1: Instrument what the tool actually received
Do not assert on the decision you sent. Assert on the arguments that reached the function body. Wrap each tool so every invocation is recorded.
CALLS = []
def recording(fn):
def wrapper(**kwargs):
CALLS.append({"tool": fn.__name__, "args": dict(kwargs)})
return fn(**kwargs)
wrapper.__name__ = fn.__name__
wrapper.__doc__ = fn.__doc__
return wrapper
Register the wrapped version with the agent. Now CALLS is ground truth: it contains every
argument set that made it past the approval gate, in order.
Step 2: Write the assertion that would have caught this
def test_edit_is_the_only_call_that_runs():
CALLS.clear()
cfg = {"configurable": {"thread_id": "t-edit-1"}}
agent.invoke({"messages": [("user", "Email the customer list about Q3 pricing")]}, cfg)
agent.invoke(
Command(resume={"decisions": [{
"type": "edit",
"edited_action": {"name": "send_email",
"args": {"to": "ops-test@acme.com", "subject": "Q3 pricing"}},
}]}),
cfg,
)
sends = [c for c in CALLS if c["tool"] == "send_email"]
assert len(sends) == 1, f"send_email ran {len(sends)} times: {sends}"
assert sends[0]["args"]["to"] == "ops-test@acme.com"
The first assertion is the important one. The naive test only checks the second, which passes even while the agent is queueing up the original action behind it.
Step 3: Remove edit from destructive tools
This is the one-line mitigation and it is the right default. If a tool moves money, deletes rows or talks to a customer, the human should not be silently rewriting its arguments. They should reject with a reason and let the model produce a new proposal that the model itself understands and will not contradict.
HumanInTheLoopMiddleware(
interrupt_on={
"send_email": {"allowed_decisions": ["approve", "reject"]},
"issue_refund": {"allowed_decisions": ["approve", "reject"]},
"search_docs": False,
}
)
A rejection with feedback goes back into the message history as something the model can read and act on. An edit does not. That asymmetry is the entire bug, and constraining the decision set sidesteps it without waiting for an upstream fix.
| Decision | What runs | What the model learns | Safe for destructive tools |
|---|---|---|---|
approve | the original call | the result | yes |
edit | the edited call | little or nothing about the correction | no |
reject | nothing | the rejection and your reason | yes |
respond | nothing | your message as a synthetic tool result | yes |
Step 4: Put the real guard inside the tool
Middleware runs inside the agent loop. Anything inside the agent loop is subject to the model's next idea. The only place a control cannot be re-planned around is the function that performs the side effect.
import hashlib, json
APPROVALS = {} # in production: a table, with a TTL and an operator id
def fingerprint(tool, args):
blob = json.dumps({"tool": tool, "args": args}, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(blob.encode()).hexdigest()
def approve(thread_id, tool, args, operator):
APPROVALS[(thread_id, fingerprint(tool, args))] = operator
def send_email(to: str, subject: str, body: str, thread_id: str) -> str:
"""Send an email. Requires a recorded human approval for these exact arguments."""
key = (thread_id, fingerprint("send_email", {"to": to, "subject": subject, "body": body}))
if key not in APPROVALS:
raise PermissionError(f"no human approval on record for send_email to={to}")
del APPROVALS[key] # single use: a second identical call must be re-approved
return _smtp_send(to, subject, body)
Two properties matter here. The approval is keyed on a hash of the exact arguments, so a re-proposed original call does not match an approval recorded for the edited one. And it is single use, so the model cannot collect one approval and then loop. That second property is the same reasoning behind writing migrations that survive running twice and handling duplicate payment callbacks: assume the thing fires more than once, and make the second firing harmless.
Verification
Run the test from step 2 and read the failure message before you apply the fix, so you know the test has teeth:
pytest -q tests/test_hitl.py
E AssertionError: send_email ran 2 times: [{'tool': 'send_email', 'args':
E {'to': 'ops-test@acme.com', ...}}, {'tool': 'send_email', 'args':
E {'to': 'all-customers@acme.com', ...}}]
With the tool-level guard in place the second call raises PermissionError instead of
sending, and that error goes back to the model as a tool result it can read. Add one more assertion to
lock the behaviour in:
assert all(c["args"]["to"] == "ops-test@acme.com" for c in sends)
In production the equivalent check is a log line, not a test. Emit one structured event per tool execution containing the tool name, the argument fingerprint and whether an approval record was found, then alert on any execution where it was not. If that alert never fires, your gate is real. If it fires weekly, it was never a gate.
What people get wrong
"We added an approval UI, so destructive actions are safe." An approval UI on top of an agent loop is a suggestion box unless something below the loop enforces it. This is the most common misreading of human-in-the-loop, and it is why the pattern gets sold as a compliance control when it is really a review convenience. I wrote about the broader version of this in keeping people genuinely in control of AI, and about what happens when agents act without one in the promise and peril of AI that acts.
Lowering the recursion limit to stop the duplicate. This turns a wrong action into a wrong action plus a truncated run. The duplicate is a symptom of the model still holding its original plan, and capping the loop does not change the plan.
Putting the rule in the system prompt. "Never send an email that a human has not approved" is a preference, not a permission. The model that generated the risky call is the same model you are asking to police it. That is also why prompt-level defences fail against prompt injection: the instruction and the attack arrive through the same channel.
Auto-approving anything the model marks as already reviewed. Model-supplied metadata is not evidence. If your approval store has no record keyed on the exact arguments, there is no approval.
When it is still broken
- Check your checkpointer is durable. An in-memory saver loses the interrupt when the process restarts, and a resume against a lost thread can start a fresh run that re-proposes everything. Use a real persistence backend for anything with an approval queue.
- Log the
tool_call_idon both sides. Print the ids in the interrupt payload and the ids the middleware applied decisions to. If they differ, you have the positional-matching problem, not the re-planning one. - Test parallel tool calls explicitly. A single-tool-call test does not exercise the ordering logic at all. Force the model to emit two calls in one turn and edit only the second.
- Pin the versions. This is an open issue on a fast-moving package. Pin
langchain,langchain-coreandlanggraphexactly, and re-run the test from step 2 as part of any upgrade. That test is now your regression detector for the whole approval mechanism.
The general lesson is worth more than the specific bug. Agent frameworks give you hooks that look like security controls because they sit at the right place in a diagram. They are not, and the reason is structural: everything inside the loop is an input to the next model call, and the model is allowed to disagree with it. Decide which of your tools have consequences you cannot take back and put a real check inside those functions. Then let the middleware do what it is genuinely good at, which is showing a human what is about to happen. If you are still deciding whether a given agent belongs in production at all, evaluate it before it touches a customer.
Frequently asked questions
- Why does my LangChain agent re-run the original tool call after I edited it?
- Because the edit changes the arguments of one pending tool call, not the model's plan. After the edited tool executes, the model takes another turn, sees that its stated goal is not obviously satisfied by the tool result, and generates the same proposal again. This is tracked as an open issue against langchain 1.0.x (langchain#33787) and it produces no error, so it usually escapes testing.
- Is HumanInTheLoopMiddleware a security control?
- No. It runs inside the agent loop, and everything inside the agent loop is just an input to the next model call, which the model is free to work around. Treat it as a review interface, and put the actual enforcement inside the tool function: look up a stored approval record keyed on a hash of the exact arguments, and raise if there is no match.
- Should I allow the edit decision at all?
- Not for tools with consequences you cannot undo, such as payments, deletions or outbound customer messages. Restrict those to approve and reject via allowed_decisions. A rejection with feedback re-enters the message history where the model can read and act on it, whereas an edit does not, which is exactly why edits get contradicted on the next turn.
- How do I test that my agent approval gate actually works?
- Wrap each tool so every invocation records the arguments it received, then assert on that record rather than on the decision payload you sent. The critical assertion is the call count: assert the tool ran exactly once, with the edited arguments. A test that only checks the edited call succeeded will pass while a duplicate original call is queued behind it.