What does recursionLimit actually count in createAgent? (LangChain JS)

Hi,
I’m using createAgent from the LangChain TypeScript library and I’m trying to understand how recursionLimit maps to what the agent actually does at runtime, so I can pick a sensible value and avoid hitting GRAPH_RECURSION_LIMIT.

I have recursionLimit: 50 configured for my agent with 4 tools.
In a successful run that did not hit the limit, I logged the number of AIMessages and ToolMessages produced and saw, for example, 17 model calls and 60 tool calls (77 total).
Clearly recursionLimit isn’t simply “total messages” or “modelCalls + toolCalls”, otherwise this run should have failed.

What I’d like to understand:

  1. What does recursionLimit actually count in a createAgent - supersteps, node traversals, model calls, something else?
  2. Do beforeModel / beforeAgent / wrapModelCall / wrapToolCall middleware hooks contribute to the counter, or are they transparent?

What I’m trying to achieve?

  • I want to proactively stop the agent before it hits the recursion limit, without losing the partial response the agent has accumulated so far (in particular the structured output from responseFormat).
  • From what I can tell, once GraphRecursionError is raised, invoke() throws and the intermediate state isn’t returned to the caller.

Any pointers to docs, source, or existing patterns would be much appreciated. Thanks!

My refs:

hi @nirgi

what I can see from the source code is:

recursionLimit is a LangGraph Pregel setting, not a LangChain agent setting. It limits the number of supersteps the graph is allowed to execute - i.e. how many times the Pregel scheduler ticks, not how many model calls, tool calls, or messages are produced.

That is exactly why 77 messages fit under a limit of 50:

  • One model call = 1 superstep on the model_request node
  • One round of tool execution = 1 superstep on the tools node, even if it runs many tool calls in parallel inside that step (see below)
  • A responseFormat run adds 1 extra model_request step at the end to extract the structured answer

So a run with 17 model turns and tool calls clustered into ~16 tool rounds is closer to ~33–35 supersteps - well under 50.

Middleware: beforeAgent, beforeModel, afterModel, afterAgent do add supersteps because each is compiled into its own graph node. wrapModelCall and wrapToolCall do not - they wrap the existing model_request /tools node and are invisible to the scheduler.

GraphRecursionError always throws. To finish cleanly and keep partial state, use modelCallLimitMiddleware/toolCallLimitMiddleware with exitBehavior: "end", or pair the agent with a checkpointer and read state back after the throw, or consume agent.stream(...) chunk-by-chunk.

Thanks for the response.

I’ve thought about using the modelCallLimitMiddleware or toolCallLimitMiddleware, but I could thought about the right number I should give them (to end after N counts). Do you have any tip on how can I calculate it correctly? Let’s say that I want to limit by recursionLimit of 50.

Do you have any examples for the other suggestions?

For streaming, I suppose this is an example for what you meant. Don’t we’ll have the same problem? AIMessage and ToolMessage doesn’t have any correlation to recursionLimit

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
beforeModel + 1× afterModel (50 - 0) / (1+1+2) 12 10
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)

Again - thank you very much @pawel-twardziak! Helped a lot