Skip to content

Commit 69d4f40

Browse files
fix(langchain_v1): ensure HITL middleware edits persist correctly
Fix issues #33787 and #33784 where Human-in-the-Loop middleware edits were not persisting correctly in the agent's message history. The problem occurred because the middleware was directly mutating the AIMessage.tool_calls attribute, but LangGraph's state management doesn't properly persist direct object mutations. This caused the agent to see the original (unedited) tool calls in subsequent model invocations, leading to duplicate or incorrect tool executions. Changes: - Create new AIMessage instance instead of mutating the original - Ensure message has an ID (generate UUID if needed) so add_messages reducer properly replaces instead of appending - Add comprehensive test case that reproduces and verifies the fix
1 parent 81c4f21 commit 69d4f40

File tree

3 files changed

+177
-4
lines changed

3 files changed

+177
-4
lines changed

libs/langchain_v1/langchain/agents/middleware/human_in_the_loop.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -345,7 +345,25 @@ def after_model(self, state: AgentState, runtime: Runtime) -> dict[str, Any] | N
345345
if tool_message:
346346
artificial_tool_messages.append(tool_message)
347347

348-
# Update the AI message to only include approved tool calls
349-
last_ai_msg.tool_calls = revised_tool_calls
348+
# Create a new AIMessage with updated tool calls instead of mutating the original
349+
# This ensures LangGraph's state management properly persists the changes
350+
# (fixes Issues #33787 and #33784 where edits weren't persisted correctly)
351+
#
352+
# CRITICAL: We must ensure last_ai_msg has an ID for the add_messages reducer
353+
# to properly replace it (not append a duplicate). If no ID exists, generate one.
354+
if last_ai_msg.id is None:
355+
import uuid
356+
357+
last_ai_msg.id = str(uuid.uuid4())
358+
359+
updated_ai_msg = AIMessage(
360+
content=last_ai_msg.content,
361+
tool_calls=revised_tool_calls,
362+
id=last_ai_msg.id, # Same ID ensures replacement, not appending
363+
name=last_ai_msg.name,
364+
additional_kwargs=last_ai_msg.additional_kwargs,
365+
response_metadata=last_ai_msg.response_metadata,
366+
usage_metadata=last_ai_msg.usage_metadata,
367+
)
350368

351-
return {"messages": [last_ai_msg, *artificial_tool_messages]}
369+
return {"messages": [updated_ai_msg, *artificial_tool_messages]}

libs/langchain_v1/tests/unit_tests/agents/test_middleware_agent.py

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1024,6 +1024,161 @@ def mock_capture_requests(request):
10241024
assert captured_request["action_requests"][1]["description"] == "Static description"
10251025

10261026

1027+
def test_human_in_the_loop_middleware_edit_actually_executes_with_edited_args(
1028+
sync_checkpointer: BaseCheckpointSaver,
1029+
) -> None:
1030+
"""Test that HITL edit decision properly replaces original tool call (Issues #33787, #33784).
1031+
1032+
This test reproduces the bug where after editing a tool call:
1033+
1. The edited tool executes correctly
1034+
2. BUT the agent's next model call sees the ORIGINAL (unedited) tool_calls in the AIMessage
1035+
3. This causes the agent to re-attempt the original tool call
1036+
1037+
The bug happens because HumanInTheLoopMiddleware directly modifies the AIMessage
1038+
object's tool_calls attribute, but LangGraph's state management may not properly
1039+
persist this mutation, causing subsequent reads to see the original values.
1040+
"""
1041+
# Track what arguments tools were actually called with
1042+
send_email_calls = []
1043+
1044+
@tool
1045+
def send_email_tool(to: str, subject: str, body: str) -> str:
1046+
"""Send an email to a recipient.
1047+
1048+
Args:
1049+
to: Email address of the recipient
1050+
subject: Subject line of the email
1051+
body: Body content of the email
1052+
"""
1053+
send_email_calls.append({"to": to, "subject": subject, "body": body})
1054+
return f"Email sent successfully to {to} with subject: {subject}"
1055+
1056+
# Create agent with HITL middleware
1057+
# Simulate the exact scenario from issue #33787:
1058+
# 1. First model call: agent wants to send email to [email protected]
1059+
# 2. After edit (to [email protected]), the model should see the edited params
1060+
# and not re-attempt the original call
1061+
agent = create_agent(
1062+
model=FakeToolCallingModel(
1063+
tool_calls=[
1064+
# First call: agent proposes sending to [email protected]
1065+
[
1066+
{
1067+
"args": {"to": "[email protected]", "subject": "Test", "body": "Hello"},
1068+
"id": "call_001",
1069+
"name": "send_email_tool",
1070+
}
1071+
],
1072+
# Second call (after edited tool execution): Agent should see the EDITED
1073+
# tool call in message history. If model still had original params in its
1074+
# context, it might try again. But with the fix, it sees edited params.
1075+
# For testing, we configure model to not make additional tool calls
1076+
# (empty list) to verify the agent loop completes successfully.
1077+
[], # No more tools - task completed
1078+
]
1079+
),
1080+
tools=[send_email_tool],
1081+
middleware=[
1082+
HumanInTheLoopMiddleware(
1083+
interrupt_on={
1084+
"send_email_tool": {"allowed_decisions": ["approve", "edit", "reject"]}
1085+
}
1086+
)
1087+
],
1088+
checkpointer=sync_checkpointer,
1089+
)
1090+
1091+
thread = {"configurable": {"thread_id": "test-hitl-bug-33787"}}
1092+
1093+
# === STEP 1: First invocation - should interrupt before sending email ===
1094+
result = agent.invoke(
1095+
{
1096+
"messages": [
1097+
HumanMessage(
1098+
"Send an email to [email protected] with subject 'Test' and body 'Hello'"
1099+
)
1100+
]
1101+
},
1102+
thread,
1103+
)
1104+
1105+
# Verify we got an interrupt (email not sent yet)
1106+
assert len(send_email_calls) == 0, "Email should not have been sent yet"
1107+
last_ai_msg = result["messages"][-1]
1108+
assert isinstance(last_ai_msg, AIMessage)
1109+
assert len(last_ai_msg.tool_calls) == 1
1110+
assert last_ai_msg.tool_calls[0]["args"]["to"] == "[email protected]"
1111+
1112+
# === STEP 2: Resume with edit decision - change recipient to [email protected] ===
1113+
from langgraph.types import Command
1114+
1115+
result = agent.invoke(
1116+
Command(
1117+
resume={
1118+
"decisions": [
1119+
{
1120+
"type": "edit",
1121+
"message": "Changing recipient to test address",
1122+
"edited_action": Action(
1123+
name="send_email_tool",
1124+
args={
1125+
1126+
"subject": "this is a test",
1127+
"body": "don't reply",
1128+
},
1129+
),
1130+
}
1131+
]
1132+
}
1133+
),
1134+
thread,
1135+
)
1136+
1137+
# CRITICAL ASSERTION 1: Email should be sent to EDITED address ([email protected])
1138+
assert len(send_email_calls) == 1, "Exactly one email should have been sent"
1139+
assert send_email_calls[0]["to"] == "[email protected]", (
1140+
f"Email should be sent to edited address '[email protected]', got '{send_email_calls[0]['to']}'"
1141+
)
1142+
assert send_email_calls[0]["subject"] == "this is a test"
1143+
1144+
# Verify the tool message reflects the edited execution
1145+
tool_messages = [m for m in result["messages"] if isinstance(m, ToolMessage)]
1146+
assert len(tool_messages) >= 1
1147+
assert "[email protected]" in tool_messages[-1].content
1148+
1149+
# CRITICAL ASSERTION 2: Verify the AIMessage in history was properly updated
1150+
# This is the core fix - the AIMessage with original params should be replaced
1151+
# with the edited version so that subsequent model calls see the correct context
1152+
ai_msg_with_original_id = None
1153+
for msg in result["messages"]:
1154+
if isinstance(msg, AIMessage) and getattr(msg, "id", None) == "0":
1155+
ai_msg_with_original_id = msg
1156+
break
1157+
1158+
assert ai_msg_with_original_id is not None, "Should find the AIMessage that was edited"
1159+
assert len(ai_msg_with_original_id.tool_calls) == 1
1160+
1161+
# THE KEY ASSERTION: The AIMessage stored in state should have EDITED params
1162+
# Without the fix, this would still have [email protected] due to mutation issues
1163+
assert ai_msg_with_original_id.tool_calls[0]["args"]["to"] == "[email protected]", (
1164+
f"BUG DETECTED (Issue #33787): AIMessage in state still has original params "
1165+
f"'{ai_msg_with_original_id.tool_calls[0]['args']['to']}' instead of edited '[email protected]'. "
1166+
f"The middleware should have created a NEW AIMessage with edited params to replace the original."
1167+
)
1168+
1169+
# CRITICAL ASSERTION 3: Agent should not have re-executed original tool call
1170+
# Only the EDITED call should have been executed
1171+
assert len(send_email_calls) == 1, (
1172+
f"Expected exactly 1 email (the edited one), but {len(send_email_calls)} were sent. "
1173+
f"This suggests the agent re-attempted the original tool call."
1174+
)
1175+
1176+
# Final verification: the execution log confirms only edited params were used
1177+
assert send_email_calls[0]["to"] == "[email protected]"
1178+
assert send_email_calls[0]["subject"] == "this is a test"
1179+
assert send_email_calls[0]["body"] == "don't reply"
1180+
1181+
10271182
# Tests for SummarizationMiddleware
10281183
def test_summarization_middleware_initialization() -> None:
10291184
"""Test SummarizationMiddleware initialization."""

libs/langchain_v1/uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)