`ContextEditingMiddleware` / `ClearToolUsesEdit` not clearing tool messages despite exceeding token threshold

Hi, I’m running into an issue with ContextEditingMiddleware where tool messages are never cleared even though the conversation is well past the configured token threshold.

Setup:

  • Trigger set to 20,000 tokens
  • Conversation reaches ~31,200 tokens (confirmed via LangSmith trace)
  • 20+ tool calls in the conversation
  • Tried lowering keep to 1 — still no effect

Middleware config:

ContextEditingMiddleware(
    edits=[
        ClearToolUsesEdit(
            trigger=20000,
            keep=3,
            placeholder=(
                "[CLEARED] The response has been cleared to reduce context size, "
                "please re-run the tool again if needed"
            )
        )
    ]
)

Full middleware stack:

return [
    CopilotKitMiddleware(),
    HumanInTheLoopMiddleware(
        interrupt_on={
            "remove_file": True,
            "generate_outline": True,
            "confirm_order": True,
        }
    ),
    _dynamic_prompt,
    SkillsMiddleware(...),
    TodoListMiddleware(...),
    DynamicToolScopingMiddleware(...),
    UsageTrackingMiddleware(),
    ToolRetryMiddleware(
        max_retries=3,
        backoff_factor=2.0,
        initial_delay=1.0,
        retry_on=retry_on,
        on_failure=on_failure
    ),
    ContextEditingMiddleware(
        edits=[
            ClearToolUsesEdit(
                trigger=20000,
                keep=3,
                placeholder="[CLEARED] ..."
            )
        ]
    )
]

LangSmith trace: LangSmith

Hey @pawel-twardziak, would really appreciate if you could take a look when you get a chance

[UPDATE] Narrowed it down — it looks like ContextEditingMiddleware doesn’t play well with how CopilotKit tools are defined. Disabling CopilotKitMiddleware (at the cost of losing frontend actions) fixes the issue, which points to a bug in the candidate matching logic rather than the token counting.

Updated trace here: LangSmith

hi @codeonym

sure thing, I’m on it!

what are your versions of langchain packages and copilotkit packages?

OK, here is the dump

root@ca064d726a53:/app# uv pip list | grep -iE "copilotkit|agui|langchain|langgraph|deepagents"
ag-ui-langgraph               0.0.41
copilotkit                    0.1.94
deepagents                    0.6.11
langchain                     1.3.10
langchain-anthropic           1.4.6
langchain-core                1.4.8
langchain-google-genai        4.2.5
langchain-mcp-adapters        0.3.0
langchain-openai              1.3.2
langchain-openrouter          0.2.3
langchain-protocol            0.0.18
langgraph                     1.2.6
langgraph-checkpoint          4.1.1
langgraph-checkpoint-postgres 3.1.0
langgraph-prebuilt            1.1.0
langgraph-sdk                 0.4.2

@codeonym could you try this out, whether or not yet it works?

from __future__ import annotations

from collections.abc import Sequence
from dataclasses import dataclass

from langchain_core.messages import AIMessage, ToolMessage
from langchain.agents import create_agent
from langchain.agents.middleware import ContextEditingMiddleware


@dataclass(slots=True)
class ClearToolOutputsEdit:
    """Drop-in replacement for ClearToolUsesEdit that is robust to message
    rewriting (e.g. CopilotKit frontend actions).

    Differences vs. ClearToolUsesEdit:
      * pairs tool results to tool calls via a GLOBAL id map (any AIMessage),
        not just the nearest-preceding AIMessage;
      * clears the tool OUTPUT only (keeps tool_call_id), so it never needs to
        rewrite the AIMessage and can't desync provider pairing.
    """

    trigger: int = 100_000
    keep: int = 3
    exclude_tools: Sequence[str] = ()
    placeholder: str = "[cleared]"

    def apply(self, messages, *, count_tokens) -> None:
        if count_tokens(messages) <= self.trigger:
            return

        # tool_call id -> tool name, gathered across EVERY AIMessage in the list.
        known_ids: dict[str, str] = {
            tc["id"]: tc.get("name", "")
            for m in messages
            if isinstance(m, AIMessage)
            for tc in (m.tool_calls or [])
            if tc.get("id")
        }

        tool_idxs = [i for i, m in enumerate(messages) if isinstance(m, ToolMessage)]
        # Preserve the most recent `keep` tool results.
        if self.keep:
            tool_idxs = [] if self.keep >= len(tool_idxs) else tool_idxs[: -self.keep]

        excluded = set(self.exclude_tools)
        for i in tool_idxs:
            tm = messages[i]
            if tm.response_metadata.get("context_editing", {}).get("cleared"):
                continue
            if (tm.name or known_ids.get(tm.tool_call_id, "")) in excluded:
                continue
            messages[i] = tm.model_copy(
                update={
                    "artifact": None,
                    "content": self.placeholder,
                    "response_metadata": {
                        **tm.response_metadata,
                        "context_editing": {
                            "cleared": True,
                            "strategy": "clear_tool_outputs",
                        },
                    },
                }
            )


agent = create_agent(
    model="...",
    tools=[...],
    middleware=[
        CopilotKitMiddleware(),
        # ... your other middleware, unchanged ...
        ContextEditingMiddleware(
            edits=[
                ClearToolOutputsEdit(
                    trigger=20_000,
                    keep=3,
                    placeholder="[CLEARED] The response has been cleared...",
                ),
            ],
            token_count_method="model",  # optional; makes the trigger match LangSmith
        ),
    ],
)

hey @pawel-twardziak , I have tried the code sample you provided, and yes, it worked !
here is a link to the full trace: LangSmith

Thanks again :saluting_face:
[EDIT]: Update the trace URL

Great! Hapy to help if more needed! :slight_smile:
If it solves your problem, it would be awesome if you sloved this thread for others so they can make use of it :slight_smile:

yes of course !

Thank you!