@@ -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
10281183def test_summarization_middleware_initialization () -> None :
10291184 """Test SummarizationMiddleware initialization."""
0 commit comments