Middleware
|
This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
This page was written against langchain 1.4.3, langchain-core 1.6.5 and langgraph 1.2.12 on Python 3.12, as
verified on 2026-09-29. The running example is DocsAssistant, which answers questions over this site’s
documentation with Ollama’s llama3.1:8b chat model and the nomic-embed-text embedding model over the pgvector
store from Integrating with LangChain. Ollama was not
available while the snippets were checked, so every output shown here was produced by running the code against the
real packages with a scripted fake chat model (a GenericFakeChatModel subclass that replays prepared messages and
ignores bind_tools); no output comes from a live model.
An agent is a loop: call the model, run the tools it asked for, call the model again, stop when it asks for no more
tools. Middleware is how LangChain 1.x lets you reach into that loop without rewriting it. You pass a list of
middleware to create_agent (see Agents with create_agent), and
each one can observe, change or short-circuit the agent before and after every step. Logging, retries, summarization,
approval gates, PII redaction, rate limits and prompt changes are all middleware; none of them needs a custom graph.
The framework-neutral reasons to shape context this way are covered in
Context Engineering for Agents; this page is the LangChain
mechanics.
The hook lifecycle
Middleware is not a separate runtime. create_agent returns a compiled LangGraph graph, and the hooks run inside
it, so a middleware-equipped agent can still be dropped into a larger graph as a node or subgraph and every hook keeps
running (see LangGraph Fundamentals). There are six hooks, in two
styles:
-
Node-style hooks run sequentially at fixed points and return either nothing or a dict of state updates:
before_agent,before_model,after_modelandafter_agent. -
Wrap-style hooks surround a call and decide whether, and how often, to run it:
wrap_model_callandwrap_tool_call.
once per invocation"] BA --> BM["before_model
each model call"] BM --> WM["wrap_model_call
around the model call"] WM --> AM["after_model
each model response"] AM -->|tool calls requested| WT["wrap_tool_call
around each tool call"] WT --> BM AM -->|no tool calls| AA["after_agent
once per invocation"] AA --> E([end]) BM -.->|"jump_to: end"| AA AM -.->|"jump_to: end"| AA
| Hook | Runs | Typical use |
|---|---|---|
|
Once per invocation, before the loop starts |
Load user data, validate input, start a timer |
|
Before every model call |
Trim or summarize history, enforce budgets, stop early |
|
Around every model call |
Retries, fallbacks, caching, changing the prompt, tools or model of one request |
|
Around every tool call |
Auditing, retries, error handling, blocking or rewriting a call |
|
After every model response |
Validate output, count usage, gate tool calls (approval) |
|
Once per invocation, after the loop ends |
Clean up, persist, notify |
The next snippet defines one middleware class that prints when each hook fires, and installs two instances of it
around a search_docs tool. Every middleware in an agent needs a unique name (it defaults to the class name), so the
class derives the name from a label:
from typing import Any, Callable
from langchain.agents import AgentState, create_agent
from langchain.agents.middleware import AgentMiddleware, ModelRequest, ModelResponse, ToolCallRequest
from langchain.messages import AIMessage, ToolMessage
from langchain.tools import tool
from langgraph.runtime import Runtime
from langgraph.types import Command
@tool
def search_docs(query: str) -> str:
"""Search the documentation."""
return f"3 sections match {query!r}"
class TraceMiddleware(AgentMiddleware):
def __init__(self, label: str):
super().__init__()
self.label = label
@property
def name(self) -> str: # every middleware instance needs a unique name
return f"Trace{self.label}"
def before_agent(self, state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
print(f"{self.label}.before_agent")
def before_model(self, state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
print(f"{self.label}.before_model ({len(state['messages'])} messages)")
def wrap_model_call(self, request: ModelRequest, handler: Callable[[ModelRequest], ModelResponse]) -> ModelResponse:
print(f"{self.label}.wrap_model_call -> in")
response = handler(request)
print(f"{self.label}.wrap_model_call <- out")
return response
def after_model(self, state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
print(f"{self.label}.after_model")
def wrap_tool_call(self, request: ToolCallRequest, handler: Callable[[ToolCallRequest], ToolMessage | Command]) -> ToolMessage | Command:
print(f"{self.label}.wrap_tool_call -> {request.tool_call['name']}")
return handler(request)
def after_agent(self, state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
print(f"{self.label}.after_agent")
agent = create_agent(
model, # scripted fake model in the run below: one search_docs call, then an answer
tools=[search_docs],
middleware=[TraceMiddleware("A"), TraceMiddleware("B")],
)
out = agent.invoke({"messages": [{"role": "user", "content": "What is HNSW?"}]})
print(out["messages"][-1].content)
With a model scripted to call search_docs once and then answer, this printed:
A.before_agent
B.before_agent
A.before_model (1 messages)
B.before_model (1 messages)
A.wrap_model_call -> in
B.wrap_model_call -> in
B.wrap_model_call <- out
A.wrap_model_call <- out
B.after_model
A.after_model
A.wrap_tool_call -> search_docs
B.wrap_tool_call -> search_docs
A.before_model (3 messages)
B.before_model (3 messages)
A.wrap_model_call -> in
B.wrap_model_call -> in
B.wrap_model_call <- out
A.wrap_model_call <- out
B.after_model
A.after_model
B.after_agent
A.after_agent
HNSW is a graph index.
The output shows the ordering rules. before_* hooks run in list order (A then B). wrap_* hooks nest like
function calls, so the first middleware in the list is the outermost layer: A sees the request first and the
response last. after_* hooks run in reverse list order (B then A). The loop runs before_model and
after_model once per model call (twice here), while before_agent and after_agent run once for the whole
invocation. Wrap-style hooks are layers around a call, not graph nodes, whereas each node-style hook that a
middleware implements becomes a node in the compiled graph; agent.get_graph().nodes lists names such as
PIIMiddleware[email].before_model and HumanInTheLoopMiddleware.after_model.
See LangChain — Middleware overview and LangChain — Custom middleware for the hook reference and the execution-order rules.
Writing hooks
Hooks can be written as decorated functions or as an AgentMiddleware subclass. Decorators
(@before_agent, @before_model, @after_model, @after_agent, @wrap_model_call, @wrap_tool_call, and the
convenience @dynamic_prompt) suit a single hook with no configuration. A class suits several hooks that belong
together, init-time configuration (thresholds, models), or a hook that needs both a sync and an async version
(abefore_model, aafter_model and so on).
State updates and early exit
A node-style hook returns a dict, which is merged into the agent state through the graph’s reducers (messages is
additive), or None for no change. A hook that wants to stop the run adds "jump_to": "end" and must declare
can_jump_to=["end"] on its decorator. Extra state fields come from a state_schema that extends AgentState:
from typing import Any
from langchain.agents.middleware import AgentState, after_model, before_model
from langchain.messages import AIMessage
from langgraph.runtime import Runtime
from typing_extensions import NotRequired
class CounterState(AgentState):
model_calls: NotRequired[int]
@before_model(state_schema=CounterState, can_jump_to=["end"])
def stop_after_two(state: CounterState, runtime: Runtime) -> dict[str, Any] | None:
if state.get("model_calls", 0) >= 2:
return {"messages": [AIMessage("Stopping: model-call budget used.")], "jump_to": "end"}
return None
@after_model(state_schema=CounterState)
def count_calls(state: CounterState, runtime: Runtime) -> dict[str, Any] | None:
return {"model_calls": state.get("model_calls", 0) + 1}
Run in the agent assembled in the next section, the counter reached 2 and the third model call never happened: the
last message in the result was Stopping: model-call budget used. (the complete run, with its output, is under
Custom middleware for DocsAssistant). See
LangChain — Custom middleware for the state
schema and jump rules.
Wrapping a model or tool call
A wrap-style hook receives the request and a handler. It may call the handler once (normal flow), several times
(retry), or not at all (short-circuit, for example a cache hit). To change the request, do not assign to its
attributes; request.override(…) returns a modified copy, and the handler is called with that copy. A wrap hook
can also add state updates by returning an ExtendedModelResponse that carries a Command:
from typing import Callable
from langchain.agents import AgentState, create_agent
from langchain.agents.middleware import ExtendedModelResponse, ModelRequest, ModelResponse, wrap_model_call
from langgraph.types import Command
from typing_extensions import NotRequired
class UsageState(AgentState):
last_answer_chars: NotRequired[int]
@wrap_model_call(state_schema=UsageState)
def track_answer_size(request: ModelRequest, handler: Callable[[ModelRequest], ModelResponse]) -> ExtendedModelResponse:
response = handler(request)
size = len(response.result[-1].content)
return ExtendedModelResponse(model_response=response, command=Command(update={"last_answer_chars": size}))
agent = create_agent(model, tools=[], middleware=[track_answer_size])
out = agent.invoke({"messages": [{"role": "user", "content": "What is HNSW?"}]})
print(out["last_answer_chars"])
With a fake model that answered HNSW is a graph index. this printed 22. The Command flows through the same
reducers as any other state update. See
LangChain — Custom middleware.
Built-in middleware
langchain.agents.middleware ships ready-made middleware for the recurring needs of production agents. The ones
this page covers in detail are marked; the rest are listed so you know they exist and can read their pages.
| Middleware | What it does | Covered |
|---|---|---|
|
Compresses old messages into a summary when history nears a limit |
Below |
|
Pauses before selected tool calls for approve / edit / reject |
Below |
|
Detects and blocks, redacts, masks or hashes PII |
Below |
|
Retries failed model or tool calls with exponential backoff |
Below |
|
Caps model or tool calls per run and per thread |
Below |
|
Turns the tool exceptions your |
Mentioned |
|
Falls back to alternative models when the primary fails |
Listed |
|
Clears old tool results from the context |
Listed |
|
Adds a |
Listed |
|
Uses a model to pick relevant tools before the main call |
Listed |
|
Shell session, file search tools, and model-emulated tools for tests |
Listed |
See LangChain — Built-in middleware for the complete list and each option.
Summarization
SummarizationMiddleware keeps a long conversation inside the context window. When the trigger is reached, it asks
a summarizer model to condense the older messages and replaces them with one summary message, keeping the most recent
ones as configured by keep. Both accept a token count, a message count or a fraction of the model’s context
window. Fractions rely on the model’s profile data, which is not available for every model, so use tokens or
messages when in doubt. This is text compression only; images and other media in messages that are summarized are
represented by the text summary alone.
from langchain.agents import create_agent
from langchain.agents.middleware import SummarizationMiddleware
from langgraph.checkpoint.memory import InMemorySaver
agent = create_agent(
answerer, # scripted fake model: "answer 1", "answer 2", ...
tools=[],
checkpointer=InMemorySaver(),
middleware=[SummarizationMiddleware(model=summarizer, trigger=("messages", 4), keep=("messages", 2))],
)
cfg = {"configurable": {"thread_id": "t1"}}
for q in ["What is HNSW?", "And ef_search?", "And M?"]:
out = agent.invoke({"messages": [{"role": "user", "content": q}]}, cfg)
print(len(out["messages"]), [f"{type(m).__name__}:{str(m.content)[:40]}" for m in out["messages"]])
In production summarizer would be a cheap model such as ChatOllama(model="llama3.1:8b") or the string
"claude-haiku-4-5"; here it was a fake that returned a fixed summary:
2 ['HumanMessage:What is HNSW?', 'AIMessage:answer 1']
4 ['HumanMessage:What is HNSW?', 'AIMessage:answer 1', 'HumanMessage:And ef_search?', 'AIMessage:answer 2']
4 ['HumanMessage:Here is a summary of the conversation to', 'AIMessage:answer 2', 'HumanMessage:And M?', 'AIMessage:answer 3']
After the third question the first exchange was replaced by a single summary message, and the model was called with three messages instead of five. The stored thread state itself was rewritten, so the saved history stays small. The persistence side (checkpointers, trimming and filtering alternatives) is in Short-Term Memory & Checkpointers. See LangChain — Built-in middleware.
Human-in-the-loop
HumanInTheLoopMiddleware checks every tool call the model proposes against an interrupt_on policy. A call that
matches pauses the graph with an interrupt, waits for a human decision, and only then runs, edits or skips the tool.
The state is saved by a checkpointer, so the pause can outlive the process; a checkpointer is therefore required.
Allowed decisions are approve, edit, reject and, for "ask the user" tools, respond. This is the approval gate
DocsAssistant needs before it opens a GitHub issue:
from langchain.agents import create_agent
from langchain.agents.middleware import HumanInTheLoopMiddleware
from langchain.tools import tool
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import Command
opened = []
@tool
def open_github_issue(title: str, body: str) -> str:
"""Open a GitHub issue in the docs repository."""
opened.append(title)
return f"Opened issue: {title}"
agent = create_agent(
model, # scripted fake model: one open_github_issue call, then "Issue opened."
tools=[open_github_issue],
checkpointer=InMemorySaver(),
middleware=[HumanInTheLoopMiddleware(
interrupt_on={"open_github_issue": {"allowed_decisions": ["approve", "edit", "reject"]}},
description_prefix="DocsAssistant wants to open an issue",
)],
)
cfg = {"configurable": {"thread_id": "t1"}}
result = agent.invoke({"messages": [{"role": "user", "content": "File a docs issue about ef_search"}]}, cfg, version="v2")
req = result.interrupts[0].value
print("action_requests:", req["action_requests"])
print("review_configs:", req["review_configs"])
print("issues opened before approval:", opened)
out = agent.invoke(Command(resume={"decisions": [{"type": "approve"}]}), cfg, version="v2")
print("issues opened after approval:", opened)
print(out.value["messages"][-1].content)
action_requests: [{'name': 'open_github_issue', 'args': {'title': 'Doc gap: ef_search', 'body': 'Explain ef_search.'}, 'description': "DocsAssistant wants to open an issue\n\nTool: open_github_issue\nArgs: {'title': 'Doc gap: ef_search', 'body': 'Explain ef_search.'}"}]
review_configs: [{'action_name': 'open_github_issue', 'allowed_decisions': ['approve', 'edit', 'reject']}]
issues opened before approval: []
issues opened after approval: ['Doc gap: ef_search']
Issue opened.
Nothing was opened until the resume with Command(resume={"decisions": […]}), and the resume used the same
thread_id. With version="v2" the result carries the pending actions in .interrupts; the decisions list must
have one entry per action, in the same order. Tools set to False in interrupt_on run without asking, so read-only
tools such as search_docs never interrupt. The middleware is the high-level form of the interrupt() primitive;
editing arguments, rejection feedback, time travel and the UI side are in
Human-in-the-Loop & Time Travel, and the design of
approval flows in general is in Human-in-the-Loop. See
LangChain — Human-in-the-loop.
PII
PIIMiddleware scans messages for one type of personally identifiable information and applies a strategy. Built-in
types are email, credit_card, ip, mac_address and url; any other type name needs a detector, either a
regular expression or a function. Strategies are block (raise PIIDetectionError), redact (replace with
[REDACTED_<TYPE>]), mask (keep the last digits) and hash (a deterministic hash). By default only user input
is checked (apply_to_input=True); apply_to_output and apply_to_tool_results extend it to model output and tool
results. Add one middleware per PII type:
from langchain.agents import create_agent
from langchain.agents.middleware import PIIDetectionError, PIIMiddleware
agent = create_agent(model, tools=[], middleware=[
PIIMiddleware("email", strategy="redact"),
PIIMiddleware("credit_card", strategy="mask"),
])
agent.invoke({"messages": [{"role": "user", "content": "Mail ana@example.com, card 4111 1111 1111 1111"}]})
print("model saw:", model.seen[0][-1].content) # `model` here records what it was sent
strict = create_agent(model2, tools=[], middleware=[
PIIMiddleware("api_key", detector=r"sk-[a-zA-Z0-9]{32}", strategy="block"),
])
try:
strict.invoke({"messages": [{"role": "user", "content": user_text_with_a_key}]})
except PIIDetectionError as e:
print("blocked:", type(e).__name__, "-", e)
model saw: Mail [REDACTED_EMAIL], card **** **** **** 1111
blocked: PIIDetectionError - Detected 1 instance(s) of api_key in text content
The model never saw the address or the card number, and the request carrying a key-shaped string was refused before any model call. PII policy as a whole (guardrail middleware, tool-boundary defences, permission scoping) continues in Guardrails & Security. See LangChain — Built-in middleware.
Model and tool retry
ModelRetryMiddleware and ToolRetryMiddleware retry a failed call with exponential backoff: the delay before
retry number n (counting from 0) is initial_delay * backoff_factor ** n, capped by max_delay, with optional jitter. max_retries counts
attempts after the first, retry_on narrows which exceptions are retried, and on_failure decides what happens when
retries run out: "continue" (the default) turns the failure into a message the agent can react to, "error"
re-raises. ToolRetryMiddleware also accepts a tools list to limit which tools it protects.
from langchain.agents import create_agent
from langchain.agents.middleware import ModelRetryMiddleware, ToolRetryMiddleware
agent = create_agent(
flaky_model, # fake model: raises ConnectionError twice, then works
tools=[search_docs], # raises TimeoutError twice, then returns
middleware=[
ModelRetryMiddleware(max_retries=3, initial_delay=0.0, jitter=False), # delays disabled for the demo
ToolRetryMiddleware(max_retries=3, initial_delay=0.0, jitter=False),
],
)
out = agent.invoke({"messages": [{"role": "user", "content": "What is HNSW?"}]})
print("model failures left:", flaky_model.failures, "| tool attempts:", attempts["n"], "|", out["messages"][-1].content)
# retries exhausted: on_failure="continue" (default) turns the failure into an AIMessage
agent = create_agent(always_failing_model, tools=[], middleware=[ModelRetryMiddleware(max_retries=1, initial_delay=0.0, jitter=False)])
out = agent.invoke({"messages": [{"role": "user", "content": "hi"}]})
print("exhausted:", out["messages"][-1].content)
model failures left: 0 | tool attempts: 3 | Done.
exhausted: Model call failed after 2 attempts with ConnectionError: ollama: connection reset
Both transient failures were absorbed without the agent noticing, and when a model stayed down the run ended with a
readable message instead of a stack trace. Retries are for transient errors only; a tool that fails because its
arguments are wrong should return an actionable error to the model instead (see
Tools), which is what ToolErrorMiddleware is for. Place it before
ToolRetryMiddleware (so retry is the inner layer) and give the retry middleware on_failure="error" so exceptions
reach it after the retries are spent. See
LangChain — Built-in middleware.
Call limits
Runaway loops and runaway bills are the two failures a limit prevents. ModelCallLimitMiddleware caps model calls
per invocation (run_limit) and across a whole thread (thread_limit, which needs a checkpointer), and ends
gracefully (exit_behavior="end", the default) or raises ("error"). ToolCallLimitMiddleware does the same for
tool calls, optionally for one tool_name, and its default exit_behavior="continue" blocks only the excess calls
with an error message so the model can still answer:
from langchain.agents import create_agent
from langchain.agents.middleware import ModelCallLimitMiddleware, ToolCallLimitMiddleware
from langgraph.checkpoint.memory import InMemorySaver
agent = create_agent(
looping_model, # fake model that requests get_page forever
tools=[get_page], checkpointer=InMemorySaver(),
middleware=[ModelCallLimitMiddleware(run_limit=3, exit_behavior="end")],
)
out = agent.invoke({"messages": [{"role": "user", "content": "loop"}]}, {"configurable": {"thread_id": "a"}})
print(out["messages"][-1].content)
agent = create_agent(
three_calls_model, # fake model: three get_page calls, then "Enough."
tools=[get_page],
middleware=[ToolCallLimitMiddleware(tool_name="get_page", run_limit=2)],
)
out = agent.invoke({"messages": [{"role": "user", "content": "loop"}]})
for m in out["messages"]:
if m.type == "tool":
print(m.status, "|", m.content)
Model call limits exceeded: run limit (3/3)
success | <p0>
success | <p1>
error | Tool call limit exceeded. Do not call 'get_page' again.
The first agent made three model calls and then ended with the limit message. In the second, the third get_page
call was answered with an error ToolMessage instead of being executed, and the model then produced its final
answer. See LangChain — Built-in middleware.
Custom middleware for DocsAssistant
The built-ins cover generic needs; the hooks above cover the rest. Two customisations come up in nearly every real agent: making the system prompt depend on the request, and controlling exactly what the model sees on each call, which is context engineering. This section shows both on DocsAssistant, using a runtime context object for per-request facts (see Tools for how tools read the same context).
Dynamic prompt
@dynamic_prompt is a shortcut for a wrap_model_call that replaces the system message. The function receives the
ModelRequest, whose runtime.context carries the per-invocation context and whose state carries the messages, and
returns the prompt string. DocsAssistant uses it to scope answers to one documentation area and adapt to the reader:
from dataclasses import dataclass
from langchain.agents.middleware import ModelRequest, dynamic_prompt
@dataclass
class DocsContext:
component: str
expertise: str
@dynamic_prompt
def docs_prompt(request: ModelRequest) -> str:
ctx = request.runtime.context
base = f"You are DocsAssistant. Answer only from the {ctx.component} documentation and cite page paths."
if ctx.expertise == "beginner":
base += " Explain terms briefly."
return base
Context engineering with wrap_model_call
Because a wrap hook sees the whole request, it can change any part of it for one call, without touching the saved state: the messages, the tools, the model or the system message. Two small examples follow. The first keeps only the last six messages in the request; the stored thread keeps everything, which is the same idea as trimming in Short-Term Memory & Checkpointers but done per call:
from typing import Callable
from langchain.agents.middleware import ModelRequest, ModelResponse, wrap_model_call
MAX_MESSAGES = 6
@wrap_model_call
def trim_history(request: ModelRequest, handler: Callable[[ModelRequest], ModelResponse]) -> ModelResponse:
"""Send the model only the most recent messages; the saved state keeps everything."""
if len(request.messages) > MAX_MESSAGES:
request = request.override(messages=request.messages[-MAX_MESSAGES:])
return handler(request)
Run with eleven prior messages plus a new question, the state kept all twelve messages after the answer, while the
model received ['a2', 'q3', 'a3', 'q4', 'a4', 'q5']. The second hides the heavy get_page tool until the agent has
searched at least once, so a cheap first step cannot start by loading a whole page. Together with docs_prompt, the
budget middleware from earlier and an audit hook on tool calls, the complete DocsAssistant stack is:
from typing import Callable
from langchain.agents import create_agent
from langchain.agents.middleware import ModelRequest, ModelResponse, ToolCallRequest, wrap_model_call, wrap_tool_call
from langchain.messages import ToolMessage
from langgraph.types import Command
@wrap_model_call
def hide_get_page_until_search(request: ModelRequest, handler: Callable[[ModelRequest], ModelResponse]) -> ModelResponse:
searched = any(isinstance(m, ToolMessage) and m.name == "search_docs" for m in request.messages)
if not searched:
request = request.override(tools=[t for t in request.tools if t.name != "get_page"])
return handler(request)
@wrap_tool_call
def audit_tool_calls(request: ToolCallRequest, handler: Callable[[ToolCallRequest], ToolMessage | Command]) -> ToolMessage | Command:
print("tool call:", request.tool_call["name"], request.tool_call["args"])
result = handler(request)
print("tool result:", getattr(result, "content", result))
return result
agent = create_agent(
model, # scripted fake: search_docs, then get_page, then a third call that is cut off
tools=[search_docs, get_page],
context_schema=DocsContext,
middleware=[docs_prompt, stop_after_two, count_calls, hide_get_page_until_search, audit_tool_calls],
)
out = agent.invoke(
{"messages": [{"role": "user", "content": "What is ef_search?"}]},
context=DocsContext(component="vector-rag", expertise="beginner"),
)
print("system prompt:", model.seen[0][0].content)
print("tools bound per model call:", model.tools_seen)
print("model_calls:", out["model_calls"])
tool call: search_docs {'query': 'HNSW'}
tool result: 3 sections match 'HNSW'
tool call: get_page {'path': 'modules/ROOT/pages/database/vector-rag/ann-indexes.adoc'}
tool result: <full text of modules/ROOT/pages/database/vector-rag/ann-indexes.adoc>
system prompt: You are DocsAssistant. Answer only from the vector-rag documentation and cite page paths. Explain terms briefly.
tools bound per model call: [['search_docs'], ['search_docs', 'get_page']]
model_calls: 2
Each middleware did its part: the prompt was built from the context object, only search_docs was bound to the first
model call and both tools to the second, both tool calls were logged, and the stop_after_two budget ended the run
after two model calls with the message shown earlier. What to put in the context in the first place, how to keep it
small over a long run, and how to evaluate the choices is the subject of
Context Engineering for Agents; the tool-side rules that make
this kind of hiding safe are in Tool Design. See
LangChain — Context engineering.
Choosing the order
Order matters because wrap hooks nest and node hooks run in list order, as the trace above shows. Two rules follow directly from it:
-
before_*hooks run first-to-last andafter_*hooks last-to-first, so a middleware placed earlier prepares the input that later ones see, and a middleware placed later sees the model’s output first. -
The first
wrap_*middleware in the list is the outermost layer: it sees the original request and the final response, and it is the one whose retry logic re-runs everything inside it.
The agent loop with a checkpointer, streaming and the graph-level view of these same hooks continue in LangGraph Fundamentals and Streaming.
Summary
| Need | Use |
|---|---|
Observe every step |
|
Change one model request |
|
Stop the run early |
A hook that declares |
Keep history short |
|
Ask a human first |
|
Remove PII |
|
Survive transient failures |
|
Cap cost and loops |
|