langchain - ✅(Solved) Fix HumanInTheLoopMiddleware executes rejected tool calls in LangGraph's ToolNode [1 pull requests, 1 comments, 2 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
langchain-ai/langchain#37093Fetched 2026-04-30 06:18:31
View on GitHub
Comments
1
Participants
2
Timeline
6
Reactions
0
Author
Participants
Timeline (top)
labeled ×3commented ×1cross-referenced ×1issue_type_added ×1

Error Message

Error Message and Stack Trace (if applicable)

When a tool call is rejected via decision["type"] == "reject", it should return (None, tool_message) so that the rejected tool_call is removed from the revised AIMessage. This correctly prevents ToolNode from executing it, while preserving the error ToolMessage in the state.

Root Cause

Because the rejected tool_call is not removed from the AIMessage's tool_calls list, when LangGraph's ToolNode processes the AIMessage, it blindly extracts and executes the rejected tool anyway.

Fix Action

Fix / Workaround

  • This is a bug, not a usage question.
  • I added a clear and descriptive title that summarizes this issue.
  • I used the GitHub search to find a similar question and didn't find it.
  • I am sure that this is a bug in LangChain rather than my code.
  • The bug is not resolved by updating to the latest stable version of LangChain (or the specific integration package).
  • This is not related to the langchain-community package.
  • I posted a self-contained, minimal, reproducible example. A maintainer can copy it and run it AS IS.
from typing import Any
from langchain_core.messages import AIMessage, HumanMessage
from langgraph.runtime import Runtime
from langchain.agents.middleware.types import AgentState
from langchain.agents.middleware.human_in_the_loop import HumanInTheLoopMiddleware
from unittest.mock import patch

with patch("langchain.agents.middleware.human_in_the_loop.interrupt", side_effect=mock_reject):
    result = middleware.after_model(state, Runtime())

PR fix notes

PR #37092: fix(agents): prevent tool execution when rejected in HumanInTheLoopMi…

Description (problem / solution / changelog)

Fixes #37093

When a human reviewer rejects a tool call, the HumanInTheLoopMiddleware previously returned (tool_call, tool_message). This left the rejected tool_call inside the AIMessage.tool_calls list, causing downstream components (like LangGraph's ToolNode) to erroneously execute the rejected tool anyway.

This PR fixes the issue by returning (None, tool_message) upon rejection, effectively removing the rejected tool call from the AIMessage while still appending the rejection ToolMessage to the state.

The unit tests have been updated to reflect the new expected behavior where rejected tool calls are properly removed from the AIMessage.

Changed files

  • libs/langchain_v1/langchain/agents/middleware/human_in_the_loop.py (modified, +1/-1)
  • libs/langchain_v1/tests/unit_tests/agents/middleware/implementations/test_human_in_the_loop.py (modified, +8/-10)

Code Example

from typing import Any
from langchain_core.messages import AIMessage, HumanMessage
from langgraph.runtime import Runtime
from langchain.agents.middleware.types import AgentState
from langchain.agents.middleware.human_in_the_loop import HumanInTheLoopMiddleware
from unittest.mock import patch

# 1. Initialize middleware
middleware = HumanInTheLoopMiddleware(
    interrupt_on={"test_tool": {"allowed_decisions": ["reject"]}}
)

# 2. Simulate an AI message asking to call a tool
ai_message = AIMessage(
    content="Calling tool",
    tool_calls=[{"name": "test_tool", "args": {"input": "test"}, "id": "call_123"}],
)
state = AgentState[Any](messages=[HumanMessage(content="Hello"), ai_message])

# 3. Mock the human reviewer rejecting the tool call
def mock_reject(_: Any) -> dict[str, Any]:
    return {"decisions": [{"type": "reject", "message": "No"}]}

with patch("langchain.agents.middleware.human_in_the_loop.interrupt", side_effect=mock_reject):
    result = middleware.after_model(state, Runtime())

updated_ai_message = result["messages"][0]

# BUG: The rejected tool call is STILL present in the AIMessage
assert len(updated_ai_message.tool_calls) == 1
print("BUG: Tool call was not removed:", updated_ai_message.tool_calls)

---

N/A - This is a logical bug, not a crash.
RAW_BUFFERClick to expand / collapse

Submission checklist

  • This is a bug, not a usage question.
  • I added a clear and descriptive title that summarizes this issue.
  • I used the GitHub search to find a similar question and didn't find it.
  • I am sure that this is a bug in LangChain rather than my code.
  • The bug is not resolved by updating to the latest stable version of LangChain (or the specific integration package).
  • This is not related to the langchain-community package.
  • I posted a self-contained, minimal, reproducible example. A maintainer can copy it and run it AS IS.

Package (Required)

  • langchain
  • langchain-openai
  • langchain-anthropic
  • langchain-classic
  • langchain-core
  • langchain-model-profiles
  • langchain-tests
  • langchain-text-splitters
  • langchain-chroma
  • langchain-deepseek
  • langchain-exa
  • langchain-fireworks
  • langchain-groq
  • langchain-huggingface
  • langchain-mistralai
  • langchain-nomic
  • langchain-ollama
  • langchain-openrouter
  • langchain-perplexity
  • langchain-qdrant
  • langchain-xai
  • Other / not sure / general

Related Issues / PRs

Bug Description

When using HumanInTheLoopMiddleware with LangGraph, if a human reviewer rejects a tool call, the middleware returns (tool_call, tool_message) in its _process_decision method.

Because the rejected tool_call is not removed from the AIMessage's tool_calls list, when LangGraph's ToolNode processes the AIMessage, it blindly extracts and executes the rejected tool anyway.

Expected Behavior

When a tool call is rejected via decision["type"] == "reject", it should return (None, tool_message) so that the rejected tool_call is removed from the revised AIMessage. This correctly prevents ToolNode from executing it.

Related PR: https://github.com/langchain-ai/langchain/pull/37092

Reproduction Steps / Example Code (Python)

from typing import Any
from langchain_core.messages import AIMessage, HumanMessage
from langgraph.runtime import Runtime
from langchain.agents.middleware.types import AgentState
from langchain.agents.middleware.human_in_the_loop import HumanInTheLoopMiddleware
from unittest.mock import patch

# 1. Initialize middleware
middleware = HumanInTheLoopMiddleware(
    interrupt_on={"test_tool": {"allowed_decisions": ["reject"]}}
)

# 2. Simulate an AI message asking to call a tool
ai_message = AIMessage(
    content="Calling tool",
    tool_calls=[{"name": "test_tool", "args": {"input": "test"}, "id": "call_123"}],
)
state = AgentState[Any](messages=[HumanMessage(content="Hello"), ai_message])

# 3. Mock the human reviewer rejecting the tool call
def mock_reject(_: Any) -> dict[str, Any]:
    return {"decisions": [{"type": "reject", "message": "No"}]}

with patch("langchain.agents.middleware.human_in_the_loop.interrupt", side_effect=mock_reject):
    result = middleware.after_model(state, Runtime())

updated_ai_message = result["messages"][0]

# BUG: The rejected tool call is STILL present in the AIMessage
assert len(updated_ai_message.tool_calls) == 1
print("BUG: Tool call was not removed:", updated_ai_message.tool_calls)

Error Message and Stack Trace (if applicable)

N/A - This is a logical bug, not a crash.

Description

Bug Description

When using HumanInTheLoopMiddleware with LangGraph, if a human reviewer rejects a tool call, the middleware returns (tool_call, tool_message) in its _process_decision method.

Because the rejected tool_call is not removed from the AIMessage's tool_calls list, when LangGraph's ToolNode processes the AIMessage, it blindly extracts and executes the rejected tool anyway (ignoring the appended ToolMessage indicating the rejection).

Expected Behavior

When a tool call is rejected via decision["type"] == "reject", it should return (None, tool_message) so that the rejected tool_call is removed from the revised AIMessage. This correctly prevents ToolNode from executing it, while preserving the error ToolMessage in the state.

Steps to Reproduce

  1. Create a LangGraph agent with HumanInTheLoopMiddleware interrupting a tool.
  2. Trigger the interrupt and send a reject decision back to the state.
  3. Observe that despite the rejection, LangGraph's ToolNode still executes the tool in the next step because the tool call remains in the AIMessage.

I have already prepared a PR with the fix and updated the unit tests.

System Info

System Information

OS: Darwin OS Version: Darwin Kernel Version 24.6.0: Mon Jan 19 22:01:58 PST 2026; root:xnu-11417.140.69.708.3~1/RELEASE_ARM64_T6041 Python Version: 3.10.18 (main, Jun 3 2025, 18:23:41) [Clang 17.0.0 (clang-1700.0.13.3)]

Package Information

langchain_core: 0.3.74 langsmith: 0.4.16 langchain_anthropic: 0.3.19 langchain_google_genai: 2.1.9 langchain_openai: 0.3.31 langgraph_sdk: 0.2.3

Optional packages not installed

langserve

Other Dependencies

anthropic<1,>=0.64.0: Installed. No version info available. filetype: 1.2.0 google-ai-generativelanguage: 0.6.18 httpx<1,>=0.23.0: Installed. No version info available. httpx>=0.25.2: Installed. No version info available. jsonpatch<2.0,>=1.33: Installed. No version info available. langchain-core<1.0.0,>=0.3.74: Installed. No version info available. langsmith-pyo3>=0.1.0rc2;: Installed. No version info available. langsmith>=0.3.45: Installed. No version info available. openai-agents>=0.0.3;: Installed. No version info available. openai<2.0.0,>=1.99.9: Installed. No version info available. opentelemetry-api>=1.30.0;: Installed. No version info available. opentelemetry-exporter-otlp-proto-http>=1.30.0;: Installed. No version info available. opentelemetry-sdk>=1.30.0;: Installed. No version info available. orjson>=3.10.1: Installed. No version info available. orjson>=3.9.14;: Installed. No version info available. packaging>=23.2: Installed. No version info available. pydantic: 2.11.7 pydantic<3,>=1: Installed. No version info available. pydantic<3.0.0,>=2.7.4: Installed. No version info available. pydantic>=2.7.4: Installed. No version info available. pytest>=7.0.0;: Installed. No version info available. PyYAML>=5.3: Installed. No version info available. requests-toolbelt>=1.0.0: Installed. No version info available. requests>=2.0.0: Installed. No version info available. rich>=13.9.4;: Installed. No version info available. tenacity!=8.4.0,<10.0.0,>=8.1.0: Installed. No version info available. tiktoken<1,>=0.7: Installed. No version info available. typing-extensions>=4.7: Installed. No version info available. vcrpy>=7.0.0;: Installed. No version info available. zstandard>=0.23.0: Installed. No version info available.

extent analysis

TL;DR

The HumanInTheLoopMiddleware should return (None, tool_message) when a tool call is rejected to prevent ToolNode from executing the rejected tool.

Guidance

  • Review the _process_decision method in HumanInTheLoopMiddleware to ensure it returns (None, tool_message) when a rejection decision is made.
  • Verify that the AIMessage's tool_calls list is updated correctly after a rejection decision.
  • Check the ToolNode processing logic to ensure it handles None tool calls correctly.
  • Test the fix with the provided reproduction steps to ensure the rejected tool call is not executed.

Example

def _process_decision(self, decision: dict[str, Any]) -> tuple[Any, Any]:
    if decision["type"] == "reject":
        return None, decision["message"]
    #... existing logic...

Notes

The fix is already prepared in a PR, but it's essential to review and test the changes to ensure the correct behavior.

Recommendation

Apply the workaround by updating the _process_decision method to return (None, tool_message) when a rejection decision is made, as this will prevent ToolNode from executing the rejected tool.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING

langchain - ✅(Solved) Fix HumanInTheLoopMiddleware executes rejected tool calls in LangGraph's ToolNode [1 pull requests, 1 comments, 2 participants]