Re-Tuning an Agent's Step Limit After a Model Migration
9 min read · updated August 11, 2026
Nothing about the agent changed except the model string, and now a third of runs die on a limit that had not fired in months. The limit is not wrong. It was derived from a model that is no longer answering.
The error
In LangGraph the failure arrives as langgraph.errors.GraphRecursionError with the message Recursion limit of 25 reached without hitting a stop condition. Twenty-five is the framework default, applied per invocation and overridable through the config key recursion_limit. In the OpenAI Agents SDK the equivalent is a MaxTurnsExceeded exception raised when a run passes the max_turns value handed to Runner.run, Runner.run_sync or Runner.run_streamed. Hand-rolled loops usually raise something local: a for loop over a range, and a bare exception when it falls out the bottom.
All three are the same guard under different names, and all three fail the same way after a migration: the number was chosen once, when somebody watched a few runs, added slack, and never looked again. It encoded one model’s habits.
What actually counts as a step
Before changing the number, find out what the counter counts, because the three frameworks above do not agree and the disagreement is most of the surprise.
- LangGraph counts node executions, not model calls. A graph with a retrieval node, a model node and a validation node burns three of its twenty-five on every pass through the cycle. Add a reflection node during the migration and the effective ceiling drops by a quarter without anybody touching
recursion_limit. - Turn-based runners count assistant turns. A turn that emits four tool calls in one response is one turn; a model that emits the same four calls serially, waiting for each result, is four. This is the single largest source of step-count difference between model families, and it moves in both directions.
- Retries may or may not be inside the counter. If your tool wrapper retries a flaky HTTP call internally, the agent loop never sees it. If the retry is implemented by feeding the error back to the model and asking it to try again, every retry is a step.
So “the agent takes more steps now” is at least three different claims. Establish which one you have before you tune anything, because the fix for a parallel-tool-call difference is not the fix for an extra graph node.
Why the same task now takes more of them
Given the same tools and the same instruction, two models can reach the same answer along paths of very different length, for reasons that are not defects in either.
A model that emits tool calls one at a time will always use more turns than one that batches independent calls into a single assistant message, even when both are correct. A model that has been tuned to verify before answering will read a file it has already summarised. A model with a different tolerance for ambiguity will ask a clarifying question where the old one guessed, and a clarifying question in an unattended agent is a wasted round trip against a user who is not there. None of these is visible in an evaluation that only scores the final answer, which is why the step ceiling is often the first place a migration surfaces at all.
There is also a compounding effect worth naming. Step limits are usually set generously enough that the ordinary case has plenty of headroom, so the runs that hit the ceiling are the long tail: the ambiguous ticket, the malformed document, the tool that returned an empty list. A modest shift in the median step count moves that tail across the ceiling far more than it moves the median, which is why the symptom appears as “a third of runs” rather than as a gradual drift. See how planning behaviour changes across a migration for the shape of that shift.
Re-deriving the ceiling from your own traffic
Do not double the number. Doubling it converts a loud failure into a quiet bill. Derive it the way it should have been derived the first time: from the distribution of step counts on runs that succeeded.
- Instrument the loop so every run emits its final step count and its outcome, whether or not it hit the ceiling. In LangGraph the counter is already in the config metadata as
langgraph_step; in a turn-based runner, count assistant messages in the resulting item list. - Raise the ceiling temporarily, on a sampled slice of traffic only, to something you are certain is above the tail — and pair it with a hard cost budget so the experiment cannot run away. You are trying to observe the natural distribution, which a truncating ceiling hides from you.
- Build the histogram of step counts over successful runs on the new model. Take the 99th percentile. That is your candidate ceiling.
- Inspect the runs above it by hand. If they are genuinely stuck — calling the same tool with the same arguments, or oscillating between two — the ceiling is doing its job and should not move to accommodate them.
- Set the ceiling to the 99th percentile plus a small fixed margin, and record in a comment which model string and which date it was derived from. The next migration needs to know the number was measured, not guessed.
# LangGraph: raise the ceiling for the sampled slice only
result = graph.invoke(
state,
config={"recursion_limit": 60, "metadata": {"probe": "step-histogram"}},
)
# Agents SDK: the same idea, one knob
result = await Runner.run(agent, prompt, max_turns=60)The ceiling was doing two jobs
A step limit is usually asked to be both a liveness guard (this loop is not making progress, kill it) and a cost guard (this run has become more expensive than it is worth). Those two jobs want different numbers, and they diverge sharply on migration, because a model whose per-step cost differs from the old one breaks the coincidence that made one number serve both.
Split them. Keep the step ceiling as the liveness guard, set from the histogram above, and add an explicit accumulated-token or accumulated-cost budget checked between steps, which stops a run for a reason you can explain to whoever pays for it. A reasoning-heavy model can spend more inside one step than the old model spent across five, so a run that stays comfortably inside the step ceiling can still be the most expensive thing that happened that day.
Add a third guard that neither of those covers: a no-progress detector. Hash the tool name and arguments of each call and stop when the same hash appears three times in a row. That catches the oscillation case immediately rather than at step twenty-five, and it is the one guard whose threshold does not need re-deriving after a migration. Testing the step budget and testing the cost budget stop cover how to assert each of these fires, which matters more than usual once there are three of them and only one is exercised by ordinary traffic.
max_turns behaviour are as documented at the time of writing; check the version you have pinned rather than trusting this paragraph.