"""Custom AgentMiddleware that emits Prometheus metrics + OpenTelemetry spans for every tool call and every model invocation. This replaces the previous monkey-patching of each tool's ``.func`` (see the old ``agent._instrument_tools``). Reasons to switch: 1. The middleware runs **once per request** at the same place the agent framework actually calls the model / tool, so the timing it records is the end-to-end wall time including LangGraph's framework overhead — not just the user function's body. 2. We get a unified interception point for **both** the sync and async paths. 3. Adding new middleware (rate-limiting, caching, redaction…) is now a one-line append in ``agent.py`` instead of an ever-growing decorator stack. 4. It is the supported extension point per the deepagents harness docs. """ from __future__ import annotations import contextlib import time from typing import Any, Callable from langchain.agents.middleware.types import ( AgentMiddleware, AgentState, ContextT, ModelRequest, ModelResponse, ) from langchain.tools.tool_node import ToolCallRequest from .metrics import LLM_CALLS, TOOL_CALLS, TOOL_DURATION from .tracing import span class MetricsMiddleware(AgentMiddleware): """Counts model + tool calls, observes their durations, traces them. Plays nice with the existing CostCallback on the model (which handles token+USD accounting): the middleware only counts calls, while the callback consumes the per-token usage payload. """ # AgentMiddleware sets `name` from the class name when unset; this is # the canonical handle used by HarnessProfile.excluded_middleware. name = "MetricsMiddleware" # ---- tool interception ------------------------------------------------ def wrap_tool_call( self, state: AgentState, request: ToolCallRequest, next_: Callable[[AgentState, ToolCallRequest], Any], ) -> Any: tool_name = getattr(request, "name", None) or getattr( getattr(request, "tool_call", None), "name", "unknown" ) t0 = time.perf_counter() cm = span(f"tool.{tool_name}", **{"tool.name": tool_name}) cm.__enter__() try: result = next_(state, request) TOOL_CALLS.labels(tool_name, "ok").inc() return result except Exception: TOOL_CALLS.labels(tool_name, "error").inc() raise finally: TOOL_DURATION.labels(tool_name).observe(time.perf_counter() - t0) with contextlib.suppress(Exception): cm.__exit__(None, None, None) async def awrap_tool_call( self, state: AgentState, request: ToolCallRequest, next_: Callable[[AgentState, ToolCallRequest], Any], ) -> Any: tool_name = getattr(request, "name", None) or getattr( getattr(request, "tool_call", None), "name", "unknown" ) t0 = time.perf_counter() cm = span(f"tool.{tool_name}", **{"tool.name": tool_name}) cm.__enter__() try: result = await next_(state, request) TOOL_CALLS.labels(tool_name, "ok").inc() return result except Exception: TOOL_CALLS.labels(tool_name, "error").inc() raise finally: TOOL_DURATION.labels(tool_name).observe(time.perf_counter() - t0) with contextlib.suppress(Exception): cm.__exit__(None, None, None) # ---- model interception ----------------------------------------------- def wrap_model_call( self, state: AgentState, request: ModelRequest, next_: Callable[[AgentState, ModelRequest], ModelResponse], ) -> ModelResponse: # We don't double-count tokens here (CostCallback does that). We # only count model invocations + trace them, which the cost callback # is intentionally agnostic about. model_name = "unknown" try: model = getattr(request, "model", None) model_name = ( getattr(model, "model_name", None) or getattr(model, "model", None) or "unknown" ) except Exception: pass cm = span("model.call", **{"model": str(model_name)}) cm.__enter__() try: response = next_(state, request) # Provider isn't always knowable here; record under "via_middleware" # so we can distinguish from the callback-based counter. LLM_CALLS.labels("via_middleware", str(model_name)).inc() return response finally: with contextlib.suppress(Exception): cm.__exit__(None, None, None) async def awrap_model_call( self, state: AgentState, request: ModelRequest, next_: Callable[[AgentState, ModelRequest], ModelResponse], ) -> ModelResponse: model_name = "unknown" try: model = getattr(request, "model", None) model_name = ( getattr(model, "model_name", None) or getattr(model, "model", None) or "unknown" ) except Exception: pass cm = span("model.call", **{"model": str(model_name)}) cm.__enter__() try: response = await next_(state, request) LLM_CALLS.labels("via_middleware", str(model_name)).inc() return response finally: with contextlib.suppress(Exception): cm.__exit__(None, None, None)