Middleware¶
Middleware is a class whose hooks run your code around a run: around the graph, each node, each model call and each tool call.
from nodestep import (
END,
START,
BaseState,
Graph,
Middleware,
RunOutcome,
node,
)
from nodestep.middleware import GraphMiddlewareContext, NodeMiddlewareContext
class PrintSteps(Middleware):
def before_node(self, ctx: NodeMiddlewareContext) -> None:
print(f"step {ctx.step}: {ctx.node_name} starts")
def after_node(self, ctx: NodeMiddlewareContext) -> None:
print(f"step {ctx.step}: {ctx.node_name} returned {ctx.state}")
def on_run_end(self, ctx: GraphMiddlewareContext, outcome: RunOutcome) -> None:
print(f"run of {ctx.graph_name} ended: {outcome.status}")
class Order(BaseState):
quantity: int = 0
total: float = 0.0
@node
def price(state: Order) -> dict:
if state.quantity < 0:
raise ValueError("negative quantity")
return {"total": state.quantity * 2.5}
graph = Graph(Order, middleware=[PrintSteps()]).flow(START >> price, price >> END)
graph.invoke({"quantity": 4})
try:
graph.invoke({"quantity": -1})
except ValueError as error:
print("raised:", error)
step 0: price starts
step 0: price returned {'total': 10.0}
run of graph ended: completed
step 0: price starts
run of graph ended: failed
raised: negative quantity
The second run fails inside price, so after_node is not called, and on_run_end reports the failure.
Add middleware¶
Subclass Middleware, override the hooks you need, and pass instances to Graph(middleware=[...]) or build_react_agent(middleware=[...]). Typical uses are logging, tracing, approvals, limits and prompt changes. The API reference documents the base class, the hooks and their contexts.
Hooks¶
| Hook | Context | Called | Can replace |
|---|---|---|---|
before_graph(ctx) |
GraphMiddlewareContext |
before the first superstep of a run, also on a resume | no |
after_graph(ctx) |
GraphMiddlewareContext |
after the last superstep, only when the run completes | no |
on_run_end(ctx, outcome) |
GraphMiddlewareContext, RunOutcome |
once at every end of a run | no |
before_node(ctx) |
NodeMiddlewareContext |
before a node runs, with a copy of its input | the input, for this call only |
after_node(ctx) |
NodeMiddlewareContext |
after a node returns, with its update | the update |
on_error(ctx, error) |
NodeMiddlewareContext |
when a node raises | a result to use instead |
on_interrupt(ctx, payload) |
NodeMiddlewareContext |
inside interrupt() |
returns an answer, see Interrupts |
before_model(ctx) |
ModelMiddlewareContext |
before every call of a model_node |
the ChatRequest |
after_model(ctx) |
ModelMiddlewareContext |
after every call of a model_node |
the ChatResponse |
before_tool(ctx) |
ToolMiddlewareContext |
with the validated arguments of a tool call | the arguments; may raise ToolDeniedError |
after_tool(ctx) |
ToolMiddlewareContext |
with the validated result | the result |
on_tool_error(ctx, error) |
ToolMiddlewareContext |
when a tool raises | no |
Graph hooks are read-only. Node hooks see copies of the state, so before_node changes what the node receives, never what is stored.
Hook order and return values¶
before_*hooks run in list order.after_*hooks and the observer hooks,on_tool_errorandon_run_end, run in reverse order, so the first middleware wraps the others.- A hook may be
deforasync def; onlyon_interruptmust be synchronous. - A hook that can change something returns
ctx.replace(value), orNoneto leave it as it is. - A method named like a hook that is not one, such as a misspelled
after_tools, is refused when the class is defined.
on_run_end¶
on_run_end(ctx, outcome) is called once when a run ends, however it ends. Use it for work that every run needs at its end, such as closing a trace span.
outcome is a RunOutcome:
| Attribute | Meaning |
|---|---|
status |
"completed", "paused", "failed" or "cancelled" |
interrupts |
The pending interrupts by key; empty unless the run paused |
error |
What the run raised; set only when status is "failed" |
Only middleware whose before_graph was called gets on_run_end. The table at the end of this page lists the order and what happens when the hook raises.
on_tool_error¶
on_tool_error(ctx, error) is called when a tool raises. It runs before the tool_errors policy decides whether the error fails the run or goes to the model. It only observes: it cannot replace the error.
import asyncio
from nodestep import ScriptedChat, build_react_agent, run_agent, tool
from nodestep.chat import ChatResponse, ToolCall
from nodestep.middleware import Middleware, ToolMiddlewareContext
class LogToolErrors(Middleware):
def on_tool_error(self, ctx: ToolMiddlewareContext, error: Exception) -> None:
print(f"{ctx.tool_name} failed in task {ctx.task_id}: {error}")
@tool
def weather(city: str) -> str:
"""Return the weather for a city."""
raise TimeoutError("weather service did not answer")
chat = ScriptedChat(
[
ChatResponse(
tool_calls=[
ToolCall(id="call_1", name="weather", arguments={"city": "Oslo"})
]
),
"I cannot get the weather right now.",
]
)
agent = build_react_agent(
chat, tools=[weather], middleware=[LogToolErrors()], tool_errors="return"
)
print(asyncio.run(run_agent(agent, "Weather in Oslo?")))
print(chat.requests[1].messages[-1].content)
weather failed in task act: weather service did not answer
I cannot get the weather right now.
Tool error: TimeoutError: weather service did not answer
With tool_errors="return", the model gets the error's text as the tool result, and the run goes on.
ToolMiddlewareContext carries tool_name, the arguments in value, tool_call_id, and the task_id of the node task that runs the tool.
The hook is not called when the call never reaches the tool, for example when a before_tool hook denies it. A tracer that opens a span in before_tool must then close it elsewhere, for example in after_node or on_run_end.
Model hooks¶
before_model and after_model run around every model call of a model_node, including those in build_react_agent. ModelMiddlewareContext has the request, the response (in after_model) and ctx.model, the model name of the chat model. It also names the graph, node, run, thread, step and task.
import asyncio
from nodestep import ScriptedChat, build_react_agent, run_agent, tool
from nodestep.chat import ChatResponse, SystemMessage, ToolCall
from nodestep.middleware import Middleware, ModelMiddlewareContext, Replacement
class AddDate(Middleware):
def before_model(self, ctx: ModelMiddlewareContext) -> Replacement:
note = SystemMessage(content="Today is 2026-10-01.")
request = ctx.request.model_copy(
update={"messages": [note, *ctx.request.messages]}
)
return ctx.replace(request)
class Usage(Middleware):
def after_model(self, ctx: ModelMiddlewareContext) -> None:
print(ctx.model, ctx.node_name, ctx.response.usage)
@tool
def weather(city: str) -> str:
"""Return the weather for a city."""
return "Sunny"
chat = ScriptedChat(
[
ChatResponse(
tool_calls=[
ToolCall(id="call_1", name="weather", arguments={"city": "Oslo"})
],
usage={"input_tokens": 40, "output_tokens": 8},
),
ChatResponse(
content="It is sunny in Oslo.",
usage={"input_tokens": 70, "output_tokens": 9},
),
]
)
agent = build_react_agent(chat, tools=[weather], middleware=[AddDate(), Usage()])
print(asyncio.run(run_agent(agent, "Weather in Oslo?")))
print(chat.requests[0].messages[0].content)
scripted think {'input_tokens': 40, 'output_tokens': 8}
scripted think {'input_tokens': 70, 'output_tokens': 9}
It is sunny in Oslo.
Today is 2026-10-01.
AddDate puts a system message in front of every request, and Usage prints the token usage of each call. The last line shows that the model received the date.
Middleware tools¶
Some middleware offers tools through tools(). build_react_agent adds the tools of the middleware passed to it. A node of your own gets only the tools in its tools=, so list them yourself, as in tools=[*todo.tools(), search].
Built-in middleware¶
All of these are in nodestep.middleware.
| Middleware | What it does |
|---|---|
ToolInterruptMiddleware |
Asks a person before matching tool calls run; see Tool approvals |
ToolLimitMiddleware |
Denies tool calls over a budget, for all tools with max_calls= and per tool with per_tool={...} |
SummarizationMiddleware |
Replaces older messages with a summary before a model call once a trigger fires |
TodoListMiddleware |
Offers read_todos and write_todos tools with one list per thread |
FilesystemSkills |
Lists the skills found in SKILL.md files in the requests of the named model nodes and offers a load_skill tool |
FilesystemMemory |
Long-term memory tools; see Memory |
Rules and defaults¶
| Rule or setting | What happens |
|---|---|
Order of before_* hooks |
List order |
Order of after_* hooks, on_tool_error and on_run_end |
Reverse order, so the first middleware wraps the others |
def or async def |
Every hook may be either, except on_interrupt, which must be synchronous |
| A misspelled hook name | A method named before_*, after_* or on_* that is not a hook, such as a misspelled after_tools, raises TypeError when the class is defined |
| Return value of a hook that can replace | ctx.replace(value) or None; any other return value raises TypeError |
| Graph hooks | Read-only: their ctx.state is a copy, and returning a replacement raises GraphExecutionError |
| Node hooks | See copies of the state; before_node changes what the node receives, never what is stored |
before_graph |
Called before the first superstep of a run, also on a resume |
after_graph |
Called only when the run completes |
When on_run_end is called |
Once per run, however it ends: completed, paused, failed, cancelled, closed early by the consumer, timed out or over max_steps |
Who gets on_run_end |
Only middleware whose before_graph was called; if a before_graph raises, that middleware and the ones before it get on_run_end, the ones after it do not |
Order of on_run_end |
Reverse order, after after_graph when the run completes |
| A call refused before the run starts | Calls no hook, for example new input on a paused thread |
ctx.state in on_run_end |
A copy of the last merged state |
An on_run_end hook raises |
Every hook still runs; if the run failed, the hook's error becomes a note on the run's error, otherwise the hook's error propagates |
on_run_end returns a value |
An error: TypeError, or GraphExecutionError for a Replacement |
asyncio.CancelledError from on_run_end |
Raised after the other hooks, ahead of the run's error |
on_tool_error raises or returns a value |
It never replaces the tool's error; it is added to the tool's error as a note |
on_tool_error with tool_errors="return" |
The model gets only the error's text, without the notes |
| A call that never reaches the tool | on_tool_error is not called: a before_tool hook raised, such as ToolDeniedError from ToolLimitMiddleware, or arguments replaced by a before_tool hook failed the tool's input model |
before_tool hooks of a call that never reached the tool |
The ones that already ran get neither after_tool nor on_tool_error |
ctx.model |
The model name of the chat model, such as "scripted" for ScriptedChat |
A request changed in before_model |
Sent to the model but not stored: the state keeps the messages as they were |
| Middleware tools on a node | A node's tools= is its complete tool list; middleware adds nothing to it, so you list the tools yourself |
Middleware tools on build_react_agent |
Added to the agent's tools; a tool listed twice raises GraphConfigError |
ToolLimitMiddleware(max_calls=50) |
The budget covers one run and its resumes; calls over it are denied |
ToolLimitMiddleware budgets |
The budgets live in the instance's memory: a resume in another process starts a new budget, and the budget of a run that failed or was never resumed stays in memory |
SummarizationMiddleware triggers and policies |
A trigger such as MessageCountTrigger(max_messages=40) or TokenLimitTrigger(max_input_tokens=...) decides when to summarize; RecentMessagesPolicy or RecentTokensPolicy decides which recent messages stay |
TodoListMiddleware lists |
One list per thread, kept in this process's memory |
FilesystemSkills(nodes={...}) |
The names in nodes are not checked against the graph: a misspelled name means that node never gets the list |