Source code for langgraph_agent_toolkit.agents.components.middlewares.clear_intermediate_tool_calls

"""Middleware that drops repeated/intermediate tool calls from earlier turns to save tokens."""

import json
from collections import OrderedDict
from collections.abc import Awaitable, Callable, Collection
from typing import Literal

from langchain.agents.middleware import AgentMiddleware
from langchain.agents.middleware.types import ModelRequest, ModelResponse
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, ToolMessage

from langgraph_agent_toolkit.core.settings import settings


def _args_key(args: object) -> str:
    """Stable string key for a tool call's arguments (order-independent)."""
    if not args:
        return ""
    try:
        return json.dumps(args, sort_keys=True, default=str)
    except TypeError:
        return str(args)


def _compute_kept_ids(
    messages: list[BaseMessage],
    by: str,
    keep_last_n: int,
    exclude_tools: Collection[str],
) -> set[str]:
    """Decide which tool_call_ids to keep: the last ``keep_last_n`` per dedup key, plus excluded tools."""
    # tool_call_id -> (name, args_key) from the AIMessage tool calls (where the args live)
    call_meta: dict[str, tuple[str | None, str]] = {}
    for msg in messages:
        if isinstance(msg, AIMessage) and msg.tool_calls:
            for call in msg.tool_calls:
                cid = call.get("id")
                if cid:
                    call_meta[cid] = (call.get("name"), _args_key(call.get("args")))

    kept: set[str] = set()
    groups: "OrderedDict[object, list[str]]" = OrderedDict()
    for msg in messages:
        if not (isinstance(msg, ToolMessage) and msg.tool_call_id):
            continue
        name, args_key = call_meta.get(msg.tool_call_id, (None, ""))
        name = msg.name or name
        if name in exclude_tools:
            kept.add(msg.tool_call_id)  # protected tool: never deduped
            continue
        key: object = name if by == "name" else (name, args_key)
        groups.setdefault(key, []).append(msg.tool_call_id)

    for ids in groups.values():
        kept.update(ids[-keep_last_n:])
    return kept


def _dedup_previous_turns(
    messages: list[BaseMessage],
    by: str,
    keep_last_n: int,
    exclude_tools: Collection[str],
) -> list[BaseMessage]:
    """Keep only the last ``keep_last_n`` result(s) of each tool key; drop earlier call/result pairs.

    Pairing stays consistent: a tool call is kept iff its result is kept, so the output never contains
    an orphan in either direction. An AIMessage that loses all of its tool calls is kept only if it
    still has textual content.
    """
    kept_ids = _compute_kept_ids(messages, by, keep_last_n, exclude_tools)

    result: list[BaseMessage] = []
    for msg in messages:
        if isinstance(msg, ToolMessage):
            if msg.tool_call_id in kept_ids:
                result.append(msg)
            # else: drop intermediate tool result
        elif isinstance(msg, AIMessage) and msg.tool_calls:
            kept_calls = [call for call in msg.tool_calls if call.get("id") in kept_ids]
            if len(kept_calls) == len(msg.tool_calls):
                result.append(msg)  # nothing to drop
            elif kept_calls or (msg.content and str(msg.content).strip()):
                result.append(
                    AIMessage(
                        content=msg.content,
                        id=msg.id,
                        name=msg.name,
                        tool_calls=kept_calls,
                        response_metadata=msg.response_metadata,
                    )
                )
            # else: AIMessage had only dropped tool calls and no content -> drop entirely
        else:
            result.append(msg)
    return result


[docs] class ClearIntermediateToolCallsMiddleware(AgentMiddleware): """Reduce tokens by keeping only the most recent result(s) of each tool from *previous* turns. Ports the ``_clean_intermediate_tool_calls`` history-stripping used in downstream agents: the current turn (everything from the latest human message onward) is preserved in full, while in earlier turns each tool keeps only its last ``keep_last_n`` call/result pair(s). Defaults come from ``Settings`` (``CLEAR_INTERMEDIATE_TOOL_CALLS*``) and can be overridden per agent. With ``by="name_args"`` (the default) only genuinely identical repeat calls are collapsed, so distinct-argument calls are preserved; ``by="name"`` is the aggressive variant that collapses all repeats of a tool regardless of arguments. ``exclude_tools`` are never deduped — use it for stateful tools whose every call matters. Only the messages sent to the model are affected; persisted state is left untouched. """
[docs] def __init__( self, by: Literal["name_args", "name"] | None = None, keep_last_n: int | None = None, exclude_tools: Collection[str] | None = None, enabled: bool | None = None, ) -> None: super().__init__() self.enabled = settings.CLEAR_INTERMEDIATE_TOOL_CALLS if enabled is None else enabled self.by = by if by is not None else settings.CLEAR_INTERMEDIATE_TOOL_CALLS_BY self.keep_last_n = ( keep_last_n if keep_last_n is not None else settings.CLEAR_INTERMEDIATE_TOOL_CALLS_KEEP_LAST_N ) self.exclude_tools = set(exclude_tools or ()) if self.by not in ("name_args", "name"): raise ValueError(f"by must be 'name_args' or 'name', got {self.by!r}") if self.keep_last_n < 1: raise ValueError("keep_last_n must be >= 1")
def _process(self, messages: list[BaseMessage]) -> list[BaseMessage]: if not self.enabled: return messages last_human = next( (i for i in range(len(messages) - 1, -1, -1) if isinstance(messages[i], HumanMessage)), None, ) if last_human is None: return messages previous, current = messages[:last_human], messages[last_human:] return _dedup_previous_turns(previous, self.by, self.keep_last_n, self.exclude_tools) + current
[docs] def wrap_model_call( self, request: ModelRequest, handler: Callable[[ModelRequest], ModelResponse], ) -> ModelResponse: return handler(request.override(messages=self._process(request.messages)))
[docs] async def awrap_model_call( self, request: ModelRequest, handler: Callable[[ModelRequest], Awaitable[ModelResponse]], ) -> ModelResponse: return await handler(request.override(messages=self._process(request.messages)))