Fixing the HITL Context Gap: When Your Agent Doesn't Know It Was Edited
March 22, 2026
Fixing the HITL Context Gap: When Your Agent Doesn't Know It Was Edited
A subtle bug in Human-in-the-Loop workflows where edited tool calls leave the agent confused—and how we fixed it with better context engineering
Human-in-the-Loop (HITL) is a critical pattern for production AI systems. Before executing sensitive actions—like modifying a database query or approving a financial transaction—you pause and let a human review, approve, edit, or reject the action.
LangChain's HumanInTheLoopMiddleware provides this capability out of the box. But I discovered a subtle bug that breaks the agent's understanding of what actually happened when a human edits a tool call.
This is a story about context engineering at the middleware layer.
The Setup: A Cohort Definition Tool
We're building an AI assistant that helps researchers define patient cohorts for medical studies. The agent has a get_cohort_def tool that generates structured cohort definitions from natural language:
1from langchain.agents.middleware.human_in_the_loop import HumanInTheLoopMiddleware
2
3agent = create_agent(
4 name="explorer_chat_agent",
5 model=model,
6 tools=[get_cohort_def, search_docs, check_gene_availability],
7 middleware=[
8 HumanInTheLoopMiddleware(
9 interrupt_on={
10 get_cohort_def.name: True, # Require approval for cohort definitions
11 },
12 ),
13 ],
14)When the agent calls get_cohort_def, execution pauses. The human sees a text input box:
1Build the following Cohort?
2"<their input text>"Simple enough. If the human approves, the tool executes. If they reject, the agent gets an error message. But what happens when they edit?
The Bug: The Agent Has Amnesia
Say the human edits the tool call to change from "Men 45+" to "Women 45+"
- Updates the
tool_callson the AIMessage with the new args - Executes the tool with the edited args
- Returns the result to the agent
Here's what the agent's conversation history looks like:
1AI: I'll create a cohort definition for Men 45+.
2 [tool_call: get_cohort_def, args: {"query: "Women 45+"}
3
4Tool Result: Cohort created with 1,247 patients matching criteria.Do you see the problem?
The AI message still says Men 45+. But the tool executed with Women 45+. The agent has no idea the human changed anything.
This causes real issues:
- Confused follow-ups: "Tell me about my cohort" -> "Your cohort includes men 45 and older"
- Incorrect reasoning: The agent builds mental models based on what it thinks it requested
- Broken explanations: When asked to explain the cohort, it describes the wrong criteria
The agent's context is lying to it.
The Root Cause: Mutating Without Informing
Looking at the original middleware's _process_decision method:
1if decision["type"] == "edit" and "edit" in allowed_decisions:
2 edited_action = decision["edited_action"]
3 return (
4 ToolCall(
5 type="tool_call",
6 name=edited_action["name"],
7 args=edited_action["args"], # New args
8 id=tool_call["id"],
9 ),
10 None, # No message explaining the edit!
11 )The middleware updates the ToolCall object but returns None for the message. No record of the edit enters the conversation history.
Even worse, the AIMessage's content blocks (which contain partial_json of the original args) are never updated. So if the agent or any downstream process inspects the message content, it sees the original args, not the edited ones.
The Fix: Context Engineering for HITL
The solution is to give the agent full context about what happened. Our FixedHITLMiddleware does three things:
1. Capture the "Before" State
1def after_model(self, state: AgentState, runtime: Runtime) -> dict | None:
2 messages = state["messages"]
3 last_ai_msg_before = next(
4 (msg for msg in reversed(messages) if isinstance(msg, AIMessage)),
5 None
6 )
7 if not last_ai_msg_before or not last_ai_msg_before.tool_calls:
8 return None
9
10 # Deep copy BEFORE HITL (super().after_model mutates in place)
11 last_ai_msg_before = last_ai_msg_before.model_copy(deep=True)The original middleware mutates the AIMessage in place. We capture a copy before calling super().after_model() so we can compare.
2. Detect Edits by Diffing
1 # Perform HITL
2 results = super().after_model(state, runtime)
3 if not results:
4 return None
5
6 # Get tool_calls after edit
7 last_ai_msg, *artificial_tool_messages = results["messages"]
8 tool_calls_after = {tc["id"]: tc for tc in last_ai_msg.tool_calls}
9
10 # Detect edits
11 edit_msgs = []
12 for tool_call in last_ai_msg_before.tool_calls:
13 tool_call_after = tool_calls_after.get(tool_call["id"])
14
15 if tool_call_after and tool_call_after["args"] != tool_call["args"]:
16 # This tool call was edited!
17 ...By comparing before/after, we identify exactly which tool calls were modified and what changed.
3. Create Explicit Edit Messages
We define a custom message type to document edits:
1class EditMessage(HumanMessage):
2 """Message indicating a tool call was edited by a human."""
3
4 tool_name: str
5 tool_call_id: str
6 original_args: dict[str, Any]
7 edited_args: dict[str, Any]For each edit, we append a custom edit message:
1edit_msgs.append(
2 EditMessage(
3 content="Approved with edits",
4 tool_name=tool_call["name"],
5 tool_call_id=tool_call["id"],
6 original_args=tool_call["args"],
7 edited_args=tool_call_after["args"],
8 )
9)4. Fix the AIMessage Content
The AIMessage has a content field that may contain the original partial_json. We update it to match the edited args:
1if isinstance(last_ai_msg.content, list):
2 for block in last_ai_msg.content:
3 if not isinstance(block, dict):
4 continue
5
6 # Tag text blocks as edited
7 if block.get("type") == "text":
8 block["text"] = block["text"] + " [Edited]"
9
10 # Update tool_use blocks with new args
11 if block.get("id") == tool_call_id and block.get("type") == "tool_use":
12 block["partial_json"] = json.dumps(edited_args, default=str)5. Return the Complete Context
1return {"messages": [last_ai_msg] + artificial_tool_messages + edit_msgs}Now the conversation history looks like:
1AI: I'll create a cohort definition for women 45+. [Edited]
2 [tool_call: get_cohort_def, args: {"query": "Women 45+", ...}]
3
4Human (EditMessage): Approved with edits
5 tool_name: get_cohort_def
6 original_args: {"query": "Men 45+", ...}
7 edited_args: {"query": "Women 45+", ...}
8
9Tool Result: Cohort created with 1,247 patients matching criteria.The agent now has complete context:
- Its own message shows the actual args that were used
- An explicit human message documents what changed
- The structured
EditMessagefields enable programmatic access to the diff
Why This Matters: Context Engineering in Middleware
This bug illustrates a broader principle: every layer of your agent system is doing context engineering.
The middleware layer sits between the agent and the outside world. When it modifies tool calls without updating the conversation history, it's corrupting the agent's context. The agent makes decisions based on what it sees in its message history—if that history is incomplete or incorrect, the agent's reasoning breaks down.
Good middleware is transparent. It should:
- Document its actions in the conversation history
- Preserve consistency between message content and actual execution
- Enable the agent to reason about what happened
The fix we implemented isn't just a bug patch—it's context engineering. We're ensuring the agent has the information it needs to understand its own actions and their modifications.
Takeaways
- HITL edits need explicit documentation in the conversation history
- AIMessage content blocks may contain stale data after mutations—update them
- Custom message types (like
EditMessage) make edits programmatically accessible - Middleware is context engineering—every modification should be reflected in what the agent sees
The best HITL systems don't just pause for human approval—they ensure the human's input becomes part of the agent's understanding.