agents.exceptions.MaxTurnsExceeded: Max turns (10) exceeded
The OpenAI Agents SDK stopped your run because the agent loop went round more times than the budget allowed. The ten is the SDK’s own default, not a number you chose, and the parenthesis is the only part of the message that varies: whatever max_turns was in force is printed there. This is a turn cap, not a timeout and not a context limit, and it has its own distinct ending.
One raise site, one format string, in the SDK’s runner:
current_turn += 1 if max_turns is not None and current_turn > max_turns: ... max_turns_error = MaxTurnsExceeded(f"Max turns ({max_turns}) exceeded")
Quoted verbatim from src/agents/run.py, pinned at commit a575a6e6. MaxTurnsExceeded is declared in src/agents/exceptions.py as a subclass of AgentsException with a message attribute, and the default comes from src/agents/run_config.py, which reads DEFAULT_MAX_TURNS = 10.
In this SDK a turn is one pass of the agent loop, which is one model call. The SDK’s own documentation puts it plainly: the exception “indicates that the agent could not complete its task within the specified number of agent-loop turns (LLM calls)”. Tool calls do not each cost a turn; a turn is a model call plus whatever tools that call asked for, and then the loop comes back for another model call to look at the results. So the cheapest way to predict this error is to count the model calls your task needs: one to plan, one per batch of tool results it has to read, one to write the answer. Ten goes quickly if the model fetches one item at a time.
Two properties of the counter are worth knowing because they surprise people. It is assigned in exactly two places in the runner — set to zero when a fresh run starts, or restored from a serialized RunState when a run is resumed — and otherwise only incremented, once per pass. Nothing resets it when control is handed to another agent: a handoff continues the same loop, so a triage agent that spends four turns before delegating leaves six of a default ten for the agent that has to do the actual work. And resuming a run from its saved state restores the count rather than clearing it, so a resume does not come with a fresh budget.
You can raise max_turns, and the SDK also lets you pass max_turns=None to switch the limit off entirely. Its documentation says so in two places, so this is a supported thing to do rather than a trick. It is also the version of this fix most worth thinking twice about.
The cap is the only automatic stop on the loop. Turn it off and a model that keeps calling tools keeps calling tools; the run then ends on something that is not designed to be a stop condition — your request timeout, your rate limit, your spend, or a human noticing. Raising the number is the same move in a milder form, and it is the right move when you can state the arithmetic: this task needs about fourteen model calls, the cap is ten, so set it to twenty. When the honest answer is “as many as it takes”, the cap is not the problem. The loop has no termination argument, and the cap was the only thing that knew.
The default behaviour throws away the result of ten model calls, which is what makes the error expensive. The SDK gives you two places to intervene, and both are better than a bigger number.
Handle the error kind instead of letting it raise. Every Runner entry point accepts an error_handlers dict, and "max_turns" is one of the supported keys. A handler returns a controlled final output, so the run ends as a result you can read rather than an exception you have to catch:
def on_max_turns(_data: RunErrorHandlerInput[None]) -> RunErrorHandlerResult: return RunErrorHandlerResult( final_output="I couldn't finish within the turn limit. Please narrow the request.", include_in_history=False, ) result = Runner.run_sync( agent, "Analyze this long transcript", max_turns=3, error_handlers={"max_turns": on_max_turns}, )
That example is the SDK documentation’s own, from docs/running_agents.md at the same pinned commit. The handler returns a final output; it does not continue the run.
Read the run data off the exception. Every exception in this SDK inherits a run_data attribute, which the runner populates with the details of the run that failed — the original input, the items generated so far, the raw model responses, the agent that was current when it stopped. If you let MaxTurnsExceeded propagate and catch it, that is where the finished work is. Persist it before you re-raise, and the next attempt starts from what the last one learned rather than from the prompt.
Underneath both of those sits the change that matters most and has nothing to do with the SDK: make a turn do more. A model call that fetches one URL and a model call that fetches forty cost the same one turn. Most runs that hit ten are not complicated, they are iterative — a loop the model is walking by hand that could have been a single call to a function that loops internally. Collapse the per-item loop and the turn count stops growing with the item count.
There is a written guide: the 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, and batch.py — one standard-library file that turns a per-item loop into a single pass, which is the fix above expressed as code. It is $19. The only working way to pay for it today is 19 USDC on Base, with manual delivery: you email the transaction hash and the files come back as a reply. This page is free and ungated and sells nothing by itself.
The short version: Max turns (10) exceeded means the agent loop passed its budget of model calls, ten being the SDK’s default; handoffs share that budget and a resumed run does not get a new one. Raise it if you can do the arithmetic, switch it off only if you have another stop condition, and in either case register an error_handlers entry for "max_turns" or catch the exception and keep its run_data, so ten calls’ worth of work survives the ending.
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.