Source code for langgraph_agent_toolkit.agents.components.middlewares.clear_intermediate_tool_calls
"""Middleware that removes repeated tool calls from earlier turns."""
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:
"""Return a stable string key for tool-call arguments."""
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]:
"""Return tool-call IDs to keep."""
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)
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 the last ``keep_last_n`` result(s) for each tool key.
Keep each tool call with its result. Keep an `AIMessage` that loses all tool
calls only when it has text 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)
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)
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:
result.append(msg)
return result
[docs]
class ClearIntermediateToolCallsMiddleware(AgentMiddleware):
"""Reduce tokens by keeping recent tool results from previous turns.
The current turn remains unchanged. Earlier turns keep only the last
``keep_last_n`` call-and-result pairs for each tool. The middleware changes
only the messages sent to the model. It does not change persisted state.
"""
[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)))