Hi @nirgi
They measure different things. recursionLimit is a safety net expressed in supersteps, while runLimit on the call-limit middlewares is a business budget expressed in LLM calls or tool calls. Pick the business budget from cost/latency requirements and leave recursionLimit well above it.
That said, if you really want a formula that guarantees the middleware exits before Pregel throws, here it is. It’s just counting supersteps along the compiled graph (confirmed from ReactAgent.ts).
Superstep cost per model iteration
Let these flags be 1 if you register middleware with that hook, 0 otherwise:
ba - any beforeAgent hook
bm - any beforeModel hook
am - any afterModel hook
aa - any afterAgent hook
(Note: the ReAct graph puts all middleware beforeModel nodes in a single chain, but each is its own node - so if you have two middlewares both defining beforeModel, that’s 2 supersteps per iteration, not 1. Same for the other three.)
Let Bm, Am, Ba, Aa be the counts of middlewares using each hook, and N = number of model iterations (a “model iteration” = one trip through model_request, whether or not tools follow).
Supersteps consumed by one full run:
S = 1 (START → entry)
+ Ba (once at start)
+ N * (Bm + 1 + Am) (before_model nodes + model_request + after_model nodes, per iteration)
+ (N - 1) * 1 (tools round after every iteration except the last)
+ Aa (once at end)
Solve S ≤ recursionLimit for N:
N ≤ (recursionLimit - 1 - Ba - Aa + 1) / (Bm + 1 + Am + 1)
= (recursionLimit - Ba - Aa) / (Bm + Am + 2)
Plug in recursionLimit = 50 for a few common setups
| Middleware shape |
Formula |
Max N (iterations) |
Suggested modelCallLimitMiddleware.runLimit |
No middleware nodes (only wrapModelCall/wrapToolCall) |
(50 - 0) / (0+0+2) |
25 |
22–23 (leave 2–3 safety margin) |
Your case: 1× beforeModel, 1× beforeAgent + wrapModelCall/wrapToolCall |
(50 - 1 - 0) / (1 + 0 + 2) |
16 |
14 |
1× beforeModel + 1× afterModel |
(50 - 0) / (1+1+2) |
12 |
10 |
2× beforeModel + 1× afterModel + beforeAgent + afterAgent |
(50 - 1 - 1) / (2+1+2) |
9 |
7 |
(Leave 2–3 model-iteration budget unused so the middleware’s jumpTo: "end" has room to commit - and so adding one more hook later doesn’t silently re-introduce GraphRecursionError.)
Converting to toolCallLimitMiddleware.runLimit
toolCallLimitMiddleware.runLimit counts individual tool calls, not rounds. Parallel tool calls from the same AIMessage collapse into one superstep (one round), but they are counted one-by-one by this middleware. So for a given per-iteration parallelism k (average tools per round):
toolCallLimitMiddleware.runLimit ≈ (N - 1) * k
In the original post’s run, N = 17, k ≈ 60/16 ≈ 3.75. A runLimit: 70 on the tool-call middleware would have left comfortable headroom without changing anything else.
Rule of thumb instead of a formula
The formula is fragile: add one more beforeModel middleware and your budget shrinks. A robust setup looks like this:
createAgent({
// …
middleware: [
modelCallLimitMiddleware({ runLimit: 15, exitBehavior: "end" }),
toolCallLimitMiddleware({ runLimit: 60, exitBehavior: "end" }),
],
});
// and at invocation
await agent.invoke(input, { recursionLimit: 200 });
Meaning: pick runLimits from what the product actually needs. Keep recursionLimit 5–10× higher than the worst-case supersteps just so a runaway bug can’t loop forever.
Concrete examples of the other options
agent.stream(...) with partial-state recovery
On the concern that “AIMessage/ToolMessage don’t correlate with recursionLimit”: they don’t, and they don’t need to. You are not using the stream to bound the run - you’re using it to keep state that is already committed when the run eventually throws. Pregel commits each superstep, and stream emits the committed state; the GraphRecursionError is thrown after a committed tick that would have pushed the step counter past stop (see sources/langgraphjs/libs/langgraph-core/src/pregel/loop.ts: if (this.step > this.stop) { this.status = "out_of_steps"; ... } and the throw in pregel/index.ts). Everything the stream already yielded is yours.
import { GraphRecursionError } from "@langchain/langgraph";
const stream = await agent.stream(
{ messages: [{ role: "human", content: "…" }] },
{ recursionLimit: 50, streamMode: "values" }, // "values" = full state after each superstep
);
let lastState: any = undefined;
try {
for await (const state of stream) {
lastState = state; // keep overwriting; last one wins
}
} catch (err) {
if (err instanceof GraphRecursionError) {
// lastState still has .messages (all AI + Tool messages so far).
// .structuredResponse will NOT be set - that only happens on the final,
// no-tool-calls model turn, which never ran.
console.warn("Hit recursion limit; returning best partial state.");
} else {
throw err;
}
}
return lastState;
Stream modes are documented at LangGraph JS streaming. "values" gives you the full state after each superstep; "updates" gives you only the delta per node - use "values" for “grab the last known state” recovery.
Checkpointer + resume (best if you want to continue, not just salvage)
import { createAgent, modelCallLimitMiddleware } from "langchain";
import { MemorySaver, GraphRecursionError } from "@langchain/langgraph";
const agent = createAgent({
model: "openai:gpt-4o-mini",
tools: [...],
responseFormat: mySchema,
checkpointer: new MemorySaver(),
middleware: [
modelCallLimitMiddleware({ runLimit: 14, exitBehavior: "end" }),
],
});
const config = { configurable: { thread_id: "t1" }, recursionLimit: 50 };
try {
const result = await agent.invoke(
{ messages: [{ role: "human", content: "…" }] },
config,
);
// Happy path: middleware stopped the run. result.messages + result.structuredResponse are both set.
} catch (err) {
if (!(err instanceof GraphRecursionError)) throw err;
// Belt-and-braces path: Pregel threw anyway.
// The last committed superstep is on disk; read it back.
const snap = await agent.getState(config);
console.log("Partial messages:", snap.values.messages);
// Option: resume the same thread with a higher budget to finish.
const finished = await agent.invoke(null, { ...config, recursionLimit: 200 });
// `null` input + same thread_id = "continue from the last checkpoint".
}
agent.getState and resume-from-checkpoint are standard LangGraph persistence patterns (LangGraph JS persistence docs). The key point: with a checkpointer, the recursion throw isn’t fatal - it’s a pause.
Custom beforeModel guard using runtime state
If neither prebuilt middleware fits - for example, you want “stop after 10 minutes” or “stop when cumulative token usage > X” - write the guard yourself and return jumpTo: "end". The ReactAgent router handles this identically to modelCallLimitMiddleware (see #createBeforeModelRouter in ReactAgent.ts).
import { createMiddleware } from "langchain";
import { AIMessage } from "@langchain/core/messages";
import { z } from "zod/v3";
const deadline = createMiddleware({
name: "Deadline",
stateSchema: z.object({ startedAt: z.number().default(() => Date.now()) }),
beforeModel: {
canJumpTo: ["end"],
hook: (state) => {
if (Date.now() - state.startedAt > 30_000) {
return {
jumpTo: "end",
messages: [new AIMessage("Stopping: 30s deadline reached.")],
};
}
},
},
});
This gives you an exit that is clean (the graph terminates via END), produces a final AIMessage, and leaves state.structuredResponse untouched - so consumers downstream still see a completed run.
Why “messages don’t correlate with recursionLimit” is true but not a problem
it is correct that AIMessage + ToolMessage counts don’t map 1:1 to supersteps - but that’s a feature, not a bug:
- 4 parallel tool calls produce 4
ToolMessages but cost 1 superstep.
- 1 model call produces 1
AIMessage and costs 1 superstep (plus any beforeModel/afterModel node hops you added).
responseFormat with extract-* tool calls produces extra ToolMessages internally; whether it costs extra supersteps depends on whether the router needed a separate extract turn (see #createModelRouter in ReactAgent.ts - it short-circuits to exitNode when all tool calls begin with extract-).
So the mental model is: messages = what the model produced; supersteps = what the scheduler ticked. Count what you want to control (model calls, tool calls, tokens, wall-clock) with middleware that speaks that language. Leave recursionLimit as a coarse loop-breaker in units of Pregel supersteps.
References (additions)
- LangGraph JS - Streaming (
streamMode values, supersteps emitted after commit):
Streaming - Docs by LangChain
- LangGraph JS - Persistence (
getState, resuming a thread after error):
Persistence - Docs by LangChain
- Source (repo) -
langgraphjs/libs/langgraph-core/src/pregel/loop.ts (step/stop arithmetic; commit-then-check ordering)
- Source (repo) -
langchainjs/libs/langchain/src/agents/ReactAgent.ts (#createBeforeModelRouter, #createAfterModelRouter, #createModelRouter - how jumpTo: "end" and extract-* tool calls are handled)