langchain - 💡(How to fix) Fix ClearToolUsesEdit trigger semantics are degenerate with a persistent checkpointer — eviction re-fires every turn

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…

ClearToolUsesEdit.apply() (libs/langchain/langchain/agents/middleware/context_editing.py) checks its trigger as:

def apply(self, messages, *, count_tokens):
    tokens = count_tokens(messages)
    if tokens <= self.trigger:
        return
    candidates = [(idx, msg) for idx, msg in enumerate(messages)
                  if isinstance(msg, ToolMessage)]
    if self.keep >= len(candidates):
        candidates = []
    elif self.keep:
        candidates = candidates[: -self.keep]
    for idx, tool_message in candidates:
        if tool_message.response_metadata.get("context_editing", {}).get("cleared"):
            continue
        ...
        messages[idx] = tool_message.model_copy(update={
            "content": self.placeholder,
            "response_metadata": {
                **tool_message.response_metadata,
                "context_editing": {"cleared": True, "strategy": "clear_tool_uses"},
            },
        })

ContextEditingMiddleware.wrap_model_call runs apply against deepcopy(list(request.messages)) and ships the edited copy to the next handler. The mutation never escapes the deepcopy, so it never reaches the checkpointer.

When create_agent is wired with a checkpointer (the documented and default agent topology), this produces a degenerate trigger:

  1. Each turn loads the full, never-cleared message list from the checkpoint into request.messages.
  2. count_tokens(messages) always sees the raw, monotonically growing state.
  3. Once the raw state crosses trigger once, tokens <= self.trigger is false forever, so the edit body runs on every subsequent turn for the rest of the session.
  4. The response_metadata.context_editing.cleared flag set in apply() — and the explicit if … cleared: continue skip earlier in the same loop — were clearly designed to make subsequent passes idempotent, but because the flag lives only on the deepcopy it is discarded after each call and never observed by the next pass.

The design intent (idempotent eviction that fires once and stays under budget) is encoded in the source but not realized at runtime, because the surrounding middleware does not persist the edit.

Error Message

Error Message and Stack Trace (if applicable)

Root Cause

"""
Repro: ClearToolUsesEdit fires every turn against a growing message list,
because edits run on a deepcopy and the `cleared` flag never persists.

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.

Code Example

"""
Repro: ClearToolUsesEdit fires every turn against a growing message list,
because edits run on a deepcopy and the `cleared` flag never persists.

Run:
    pip install "langchain==1.3.1" "langchain-core==1.4.0"
    python repro.py
"""
from copy import deepcopy
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
from langchain_core.messages.utils import count_tokens_approximately
from langchain.agents.middleware.context_editing import ClearToolUsesEdit

TRIGGER = 10_000
KEEP = 5
TOOL_RESULT_CHARS = 4_000   # ~1k tokens each

edit = ClearToolUsesEdit(
    trigger=TRIGGER, keep=KEEP, placeholder="[Result cleared — re-run if needed]"
)

# Simulate the persistent checkpoint: this list grows monotonically across turns,
# exactly like state["messages"] under a real checkpointer (MemorySaver etc.).
state = [HumanMessage(content="start")]

def add_turn(state, i):
    """Each turn appends one AIMessage + one ToolMessage to state."""
    tc_id = f"call_{i}"
    state.append(AIMessage(content="", tool_calls=[{"id": tc_id, "name": "t", "args": {}}]))
    state.append(ToolMessage(content="x" * TOOL_RESULT_CHARS, tool_call_id=tc_id))

def simulate_middleware_call(state):
    """
    Mirror ContextEditingMiddleware.wrap_model_call's relevant slice:
        edited = deepcopy(list(request.messages))
        edit.apply(edited, count_tokens=count_tokens)
    """
    edited = deepcopy(state)
    edit.apply(edited, count_tokens=count_tokens_approximately)
    return edited

print(f"trigger={TRIGGER} keep={KEEP}\n")
print(f"{'turn':>4} {'tool_msgs':>10} {'raw_tok':>8} {'edited_tok':>11} "
      f"{'cleared':>8} {'fired?':>7}")

prev_cleared = 0
for turn in range(1, 26):
    add_turn(state, turn)
    edited = simulate_middleware_call(state)
    raw_tok = count_tokens_approximately(state)
    ed_tok = count_tokens_approximately(edited)
    cleared = sum(
        1 for m in edited
        if isinstance(m, ToolMessage) and m.content == edit.placeholder
    )
    fired = "yes" if cleared > prev_cleared else "no"
    print(f"{turn:>4} {sum(isinstance(m, ToolMessage) for m in state):>10} "
          f"{raw_tok:>8} {ed_tok:>11} {cleared:>8} {fired:>7}")
    prev_cleared = cleared

# Demonstrate the persistence bug: the original `state` list — the analog of
# checkpointed messages — was never mutated. Every ToolMessage in it still
# carries its full content, no `cleared` flag.
print("\nAfter 25 turns, checkpoint-side ToolMessages with real content:",
      sum(
          1 for m in state
          if isinstance(m, ToolMessage)
          and not m.response_metadata.get("context_editing", {}).get("cleared")
      ),
      "/", sum(isinstance(m, ToolMessage) for m in state))

---



---

def apply(self, messages, *, count_tokens):
    tokens = count_tokens(messages)
    if tokens <= self.trigger:
        return
    candidates = [(idx, msg) for idx, msg in enumerate(messages)
                  if isinstance(msg, ToolMessage)]
    if self.keep >= len(candidates):
        candidates = []
    elif self.keep:
        candidates = candidates[: -self.keep]
    for idx, tool_message in candidates:
        if tool_message.response_metadata.get("context_editing", {}).get("cleared"):
            continue
        ...
        messages[idx] = tool_message.model_copy(update={
            "content": self.placeholder,
            "response_metadata": {
                **tool_message.response_metadata,
                "context_editing": {"cleared": True, "strategy": "clear_tool_uses"},
            },
        })
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

No response

Reproduction Steps / Example Code (Python)

"""
Repro: ClearToolUsesEdit fires every turn against a growing message list,
because edits run on a deepcopy and the `cleared` flag never persists.

Run:
    pip install "langchain==1.3.1" "langchain-core==1.4.0"
    python repro.py
"""
from copy import deepcopy
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
from langchain_core.messages.utils import count_tokens_approximately
from langchain.agents.middleware.context_editing import ClearToolUsesEdit

TRIGGER = 10_000
KEEP = 5
TOOL_RESULT_CHARS = 4_000   # ~1k tokens each

edit = ClearToolUsesEdit(
    trigger=TRIGGER, keep=KEEP, placeholder="[Result cleared — re-run if needed]"
)

# Simulate the persistent checkpoint: this list grows monotonically across turns,
# exactly like state["messages"] under a real checkpointer (MemorySaver etc.).
state = [HumanMessage(content="start")]

def add_turn(state, i):
    """Each turn appends one AIMessage + one ToolMessage to state."""
    tc_id = f"call_{i}"
    state.append(AIMessage(content="", tool_calls=[{"id": tc_id, "name": "t", "args": {}}]))
    state.append(ToolMessage(content="x" * TOOL_RESULT_CHARS, tool_call_id=tc_id))

def simulate_middleware_call(state):
    """
    Mirror ContextEditingMiddleware.wrap_model_call's relevant slice:
        edited = deepcopy(list(request.messages))
        edit.apply(edited, count_tokens=count_tokens)
    """
    edited = deepcopy(state)
    edit.apply(edited, count_tokens=count_tokens_approximately)
    return edited

print(f"trigger={TRIGGER} keep={KEEP}\n")
print(f"{'turn':>4} {'tool_msgs':>10} {'raw_tok':>8} {'edited_tok':>11} "
      f"{'cleared':>8} {'fired?':>7}")

prev_cleared = 0
for turn in range(1, 26):
    add_turn(state, turn)
    edited = simulate_middleware_call(state)
    raw_tok = count_tokens_approximately(state)
    ed_tok = count_tokens_approximately(edited)
    cleared = sum(
        1 for m in edited
        if isinstance(m, ToolMessage) and m.content == edit.placeholder
    )
    fired = "yes" if cleared > prev_cleared else "no"
    print(f"{turn:>4} {sum(isinstance(m, ToolMessage) for m in state):>10} "
          f"{raw_tok:>8} {ed_tok:>11} {cleared:>8} {fired:>7}")
    prev_cleared = cleared

# Demonstrate the persistence bug: the original `state` list — the analog of
# checkpointed messages — was never mutated. Every ToolMessage in it still
# carries its full content, no `cleared` flag.
print("\nAfter 25 turns, checkpoint-side ToolMessages with real content:",
      sum(
          1 for m in state
          if isinstance(m, ToolMessage)
          and not m.response_metadata.get("context_editing", {}).get("cleared")
      ),
      "/", sum(isinstance(m, ToolMessage) for m in state))

Error Message and Stack Trace (if applicable)

Description

ClearToolUsesEdit.apply() (libs/langchain/langchain/agents/middleware/context_editing.py) checks its trigger as:

def apply(self, messages, *, count_tokens):
    tokens = count_tokens(messages)
    if tokens <= self.trigger:
        return
    candidates = [(idx, msg) for idx, msg in enumerate(messages)
                  if isinstance(msg, ToolMessage)]
    if self.keep >= len(candidates):
        candidates = []
    elif self.keep:
        candidates = candidates[: -self.keep]
    for idx, tool_message in candidates:
        if tool_message.response_metadata.get("context_editing", {}).get("cleared"):
            continue
        ...
        messages[idx] = tool_message.model_copy(update={
            "content": self.placeholder,
            "response_metadata": {
                **tool_message.response_metadata,
                "context_editing": {"cleared": True, "strategy": "clear_tool_uses"},
            },
        })

ContextEditingMiddleware.wrap_model_call runs apply against deepcopy(list(request.messages)) and ships the edited copy to the next handler. The mutation never escapes the deepcopy, so it never reaches the checkpointer.

When create_agent is wired with a checkpointer (the documented and default agent topology), this produces a degenerate trigger:

  1. Each turn loads the full, never-cleared message list from the checkpoint into request.messages.
  2. count_tokens(messages) always sees the raw, monotonically growing state.
  3. Once the raw state crosses trigger once, tokens <= self.trigger is false forever, so the edit body runs on every subsequent turn for the rest of the session.
  4. The response_metadata.context_editing.cleared flag set in apply() — and the explicit if … cleared: continue skip earlier in the same loop — were clearly designed to make subsequent passes idempotent, but because the flag lives only on the deepcopy it is discarded after each call and never observed by the next pass.

The design intent (idempotent eviction that fires once and stays under budget) is encoded in the source but not realized at runtime, because the surrounding middleware does not persist the edit.

Suggested fixes

Two ways to restore the intended ceiling semantics, in increasing scope:

  1. Trigger against the post-edit effective count, not the pre-edit raw count. Always compute the edit (cheap — O(N) ToolMessage scan), then evaluate if count_tokens(edited) <= self.trigger. Trigger then means "did the edit fit the budget?" — an actionable signal that can drive escalation (smaller keep, fall through to summarization) instead of a useless yes-vote. The edit still runs per turn (required, since the raw state never shrinks), but the trigger becomes diagnostic rather than gating, and the keep window stops sliding on every call.

  2. Persist the edit. Return a state update from a paired after_model hook ({"messages": [RemoveMessage(id=…), <cleared ToolMessage>]}) so the checkpointer stores the placeholder. Then the existing cleared flag check does its job — subsequent passes skip already-cleared messages, raw and effective state converge, and the trigger fires only when new tool content crosses the threshold. Matches the design encoded in the source.

Both fixes are compatible. (2) is the design-consistent one. (1) is a smaller change that at least makes the trigger semantically meaningful in the presence of a checkpointer.

Happy to take a PR (either path, or both layered) once this is triaged and you have an opinion on which direction matches the middleware's intended contract.

System Info

langchain==1.3.1 langchain-core==1.4.0

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