ninemin.lilulab.ai

“Reached max steps.” — the smolagents run that fails and answers anyway

AgentMaxStepsError: Reached max steps.

If you have found that line in a smolagents log, the interesting part is what did not happen: in the default configuration that error is recorded in the agent’s memory and never raised at your code. The agent makes one more model call, asks it for a final answer based on the steps it managed to take, and returns that string as if the run had finished. The failure is real and the result looks exactly like a success.

Where this string comes from

The run loop runs while the step number is within max_steps. When it falls out without a final answer, one method cleans up:

if not returned_final_answer and self.step_number == max_steps + 1: final_answer = self._handle_max_steps_reached(task) ... def _handle_max_steps_reached(self, task: str) -> Any: action_step_start_time = time.time() final_answer = self.provide_final_answer(task) final_memory_step = ActionStep( step_number=self.step_number, error=AgentMaxStepsError("Reached max steps.", self.logger), ... )

Quoted verbatim from src/smolagents/agents.py, pinned at commit c30b1152. In the same file, max_steps is documented as “Maximum number of steps the agent can take to solve the task” with a default of 20, and provide_final_answer is documented as providing “the final answer to the task, based on the logs of the agent’s interactions”.

So the error object exists, carries that exact message, and is attached to a step in memory. What it is not is thrown. The run continues to its normal exit and yields a final answer step like any other run.

Confirm it in ten seconds

There is a flag for this, and by default it is off. Agent.run returns the bare output string unless you ask for the full result; with return_full_result=True you get a RunResult whose state field is typed to exactly two values, "success" and "max_steps_error", and the runner sets the second one by checking whether the last step in memory carries an AgentMaxStepsError:

if self.memory.steps and isinstance(getattr(self.memory.steps[-1], "error", None), AgentMaxStepsError): state = "max_steps_error" else: state = "success"

That gives you the ten-second check, in whichever of two forms you need:

This is the part worth taking away from the page even if you never see the string: a smolagents run that hit its cap and a smolagents run that succeeded return the same type. If your pipeline checks for exceptions, it is not checking for this.

Why raising max_steps moves the wall

Twenty steps is the documented default. Raising it to forty buys the loop twenty more passes, and if the task genuinely needed twenty-six, that is the right fix and it ends here. Do the arithmetic first: steps are model calls, one per pass of the agent loop, so count the passes your task needs the way you would count iterations of any other loop.

But notice what raising it does to the failure mode. The last act of a capped run is one extra model call that writes an answer from whatever is in memory. That call happens at 20 and it happens at 200; the only thing the bigger number changes is how much partial work the summary is written from and how much you paid to get there. A loop with no reachable stopping condition will spend any budget you give it and then hand you the same kind of guessed answer, later. The cap is not what is wrong with that run.

The restructure: make each step leave something behind

Because the error is not raised, the fix is not a try block. It is two changes.

Make the state visible and act on it. Ask for the full result, branch on state, and treat "max_steps_error" as a failure in whatever surrounds the agent — retry it, escalate it, or record it — rather than passing the string downstream. A silent failure becomes a loud one, which is all you need from this part.

Write each step out as it finishes. The library calls a step callback as it finalises every step, with the step object in hand. Point that at a file or a row: one record per step, with its output. Then a run that hits the cap has still deposited nineteen finished steps on disk, and the next attempt is a continuation rather than a restart — the thing the single summarising call was trying to approximate, except real.

And the change underneath both, which is not a smolagents setting: make one step do more. A step that handles one item and a step that handles forty cost the same one model call. Runs that hit twenty steps are usually not hard, they are iterative — the model walking a list by hand because nothing was given to it that could walk the list itself. Hand it a tool that takes the whole list, and the step count stops tracking the item count.

What is behind this site

There is a written guide: the step-and-turn arithmetic as a formula you can run against a brief before you launch it, the reasons raising a cap does not finish the job — this page’s version of that, where a raised cap keeps a failure silent, is the one the guide spends longest on — and batch.py, one standard-library file that collapses a per-item loop into a single pass. It is $19. The only working way to pay today is 19 USDC on Base, and delivery is manual: you email the transaction hash and the files come back as a reply. This page is free, ungated, and sells nothing on its own.

The short version: Reached max steps. is recorded, not raised. The run ends with one extra model call that writes an answer from an incomplete transcript, so the caller sees a normal return value; the only in-band tell is RunResult.state == "max_steps_error", which you only get if you asked for the full result. Check the state, write every step out as it completes, and raise the cap only when you can say how many steps the task actually needs.

Nearby

This page counts anonymous readership. Each load sends the page path, the address of the page you came from (our server keeps only its domain), how long the page was open, whether you scrolled, and any campaign or outreach code in the link you followed; a second “engaged” event is sent once, ten seconds after the page opens — whether or not the tab is in front of you — or as soon as you scroll a quarter of it. The campaign codes from the first link you arrived on are kept in this browser’s local storage, and a later visit that arrives with no codes of its own is counted against them, outreach code included; a link carrying its own codes is used for that visit, and the stored first touch is never replaced. If a link carries a different outreach code, that later touch is recorded alongside the first rather than in place of it. The referrer of that first visit is stored in the same place and is never sent anywhere — every visit sends the referrer it actually had. A return visit is still counted, and an outreach code is removed from the address bar after it is read. The page also asks this domain for Vercel’s analytics script; on 26 September 2026 we requested that address on each of the five hosts we publish — lilulab.ai, willcall, ninemin, ghmirror and pinpoint — and every one returned HTTP 404 and no script, so no script from another company was served to your browser and none ran. The page still asks for it on every load, so this stops being true the moment that address starts answering, without a single byte of this page changing — and this page will not know it has, and will go on saying what it says. Whether Vercel counts the request at its own edge is its record and not ours; as of 26 September 2026 no one here had opened it, and we keep no copy of it. No cookie; the local storage above does that job. Some things are recorded that the list above does not name. Your browser and the network attach these to the request rather than the page sending them: the identification string your browser gives with every request; the two-letter country the network assigns your address — no city, and your address itself is never stored; and the site address you asked for. Our own server then writes its own bookkeeping about the record: the date and time of your visit, to the thousandth of a second, from our clock; which request header it took that site address from; and a number naming the record format it wrote. It also stores the domain your browser said the count was fired from, which for a normal load of this page is this site itself, and which our server records as the word “unknown” when the browser sends nothing it can read. Every row is kept in Vercel’s blob storage and not on a machine of our own, measured 26 September 2026 by reading the handler. The page sends how long it was open twice, from two different counters, and our server reads only one of the two names — so some rows carry it and some are empty (measured 26 September 2026). This page reads an outreach code from the address if one is present and keeps it in your browser, which would let us tell one reader from another — but we have sent no link for this page and hold no list of recipients for it, so as of 2 October 2026 there is no name for any code to resolve to. This page also loads a second counter of its own. It sends its own copy of the view event on every load, so one load writes two view rows and any rate measured against them reads half its true value; it also measures the time differently, counting only the seconds the page was actually in front of you where the first counter sends wall-clock time since the page opened. It also sends an event once you have had the page in front of you for ten seconds and moved the mouse, touched the screen, scrolled or pressed a key. Because both counters send an event called “engaged” under different rules, a single visit can produce two of them. The host that serves it keeps its own request logs; those are its record and not ours. Every quotation above is from the linked file at the pinned commit; the same links are listed for machine readers at /llms.txt.