</>CodeWithKarani

Human-in-the-Loop Edits Get Ignored: The Agent Runs the Original Tool Call

Karani GeoffreyKarani Geoffrey9 min read

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 edit from allowed_decisions entirely. Allow approve and reject only, 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.

  1. The model emits an AIMessage carrying one or more tool_calls.
  2. The middleware's after-model hook sees a tool name listed in interrupt_on and calls interrupt(). The graph writes a checkpoint and the invocation returns to your application with the pending action.
  3. Your UI shows it. A human sends back Command(resume={"decisions": [{"type": "edit", "edited_action": {"name": "send_email", "args": {...}}}]}).
  4. 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.
  5. The tool node executes. A ToolMessage goes 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.

original intent, never revoked 1. Model proposes send_email(to="all-customers@acme.com") 2. interrupt() - graph checkpoints and returns the pending tool call is handed to your approval UI 3. Human edits the arguments edited_action.args.to = "ops-test@acme.com" 4. Tool runs with the edited arguments this part works - the edit is real, for exactly one execution 5. Next model turn: goal still unmet, re-plan send_email(to="all-customers@acme.com") - the pre-edit call, again no error, no warning, no failed assertion
The edit is applied to a message. The plan that produced the message is untouched, so it regenerates.

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.

DecisionWhat runsWhat the model learnsSafe for destructive tools
approvethe original callthe resultyes
editthe edited calllittle or nothing about the correctionno
rejectnothingthe rejection and your reasonyes
respondnothingyour message as a synthetic tool resultyes

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.

Approval UI shows a proposal, collects a decision - advisory only Agent graph and HITL middleware the model can re-plan around anything at this layer trust boundary Tool implementation check the approval record here, keyed on a hash of the exact arguments
If the guard sits above the trust boundary it is a suggestion. Below it, it is a control.

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_id on 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-core and langgraph exactly, 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.
#LangChain#LangGraph#AI Agents#Human in the Loop#Tool Calling#Python
Keep reading

Related articles