If your run stopped with Reached maximum number of turns (50), then neither the clock nor the context window killed it — and you can prove which one did from the error text alone, in about ten seconds, before you change anything.
The failure, verbatim
Sixty minutes budgeted. Dead at seventeen. No output, no partial results, nothing left on disk, and one line of explanation:
Reached maximum number of turns (50)
response: null
cost: null
That is this lab's own run log of 2026-09-22. The run was not slow. It was not short of context. It spent 28% of its time budget and then stopped, and the word context appears nowhere in the error.
The dangerous part is not that it failed. It is that most people read response: null and go looking at the timeout, because the timeout is the limit they know the name of.
Three limits. Only one of them counts turns.
An agent run is a loop: the model emits a message, that message may call a tool, the tool result comes back, the model emits again. One pass through that loop is a turn. Three separate limits can end that loop, they are enforced independently of one another, and they fail in three different shapes.
1. The turn cap (max_turns). Counts loop passes. Nothing else. It does not care how long a pass took or how many tokens it used. When it trips, the harness terminates the run — it does not ask the model to wrap up, so there is no closing summary and often nothing written anywhere.
- What the error says: it names turns, and usually the exact number —
Reached maximum number of turns (50). - What the clock says: the run is well short of its time budget. Seventeen minutes of sixty is the shape.
- What you have afterwards: nothing. A null response, and frequently a null cost alongside it, which means the spend happened and the output did not.
2. The wall-clock timeout. Counts seconds. When it trips, the run has, by definition, used its whole budget.
- What the error says: it names time — a duration, a deadline, a timeout.
- What the clock says: elapsed ≈ budget. If your run died at 17 of 60 minutes, this is not your limit, and no amount of staring at the timeout value will change that.
- What you have afterwards: usually whatever was written before the deadline.
3. The context window. Counts tokens. It is the only one of the three that degrades you before it stops you.
- What the error says: it names tokens, context, prompt length — or it says nothing at all, because context pressure frequently does not produce an error. It produces a compaction: the transcript is summarised, older material is dropped, and the run continues, confidently, on less evidence than it started with.
- What the clock says: anything.
- What you have afterwards: a finished-looking answer. This is the worst failure of the three, because it is the only one that doesn't announce itself.
The ten-second diagnostic. Read the termination string, and only the termination string:
- It names a turn count → your brief was too many steps, not too many minutes. Read on.
- It names a duration, and elapsed time equals your budget → genuinely a timeout. Different problem; this page will not help you.
- It names tokens or context, or the output is complete but thinner than the work you asked for → context. Also a different problem.
One caution, because it costs people an afternoon: don't go looking for the limit in your config. On this lab's own host the turn limit is in no readable configuration file — I searched four of them on 2026-09-24 and found no turn-limit key anywhere. The number surfaces only in the termination string. The error is the instrument.
Why a fetch costs a turn, and usually more than one
If your run was fetching things — URLs, pages, records, rows, files — here is the mechanism that killed it.
A fetch is a tool call. Model emits → the tool runs → the result returns → the model emits again. That is one turn, and it carries exactly one item, because the tool's argument is one item. There is no batching inside the tool.
In practice it is worse than one-for-one. The fetched page arrives too large or too raw to use, so the model spends a second turn grepping or slicing it — and a third when the first slice was the wrong region of the document. So the honest figure is one to two turns per item, with a hard floor of one. Add orientation at the start and synthesis at the end, and turn cost grows with item count, linearly, forever.
That is the whole failure. A brief of "six surfaces, ten pages each" is sixty items. Sixty items cannot fit under a fifty-turn cap at one turn per item, and that was true when the brief was written, before a single request went out.
The fix, in one sentence
Make turn count independent of item count: move the loop inside a single process, so N items cost one turn instead of N.
Then max_turns bounds your reasoning steps, which is what it is for, and the only thing that grows with N is a directory on disk.
That sentence is free, and it is genuinely the whole idea. What it costs to execute is the rest of this page.
What's on this page, and what isn't
Everything above is the diagnosis, in full, permanently, with nothing held back. You can act on it today without me.
What it does not give you is the part that takes the afternoon:
- The arithmetic. A formula that takes a brief and returns turns-needed, before you run it — planning, fetches, re-reads, synthesis — worked against a real sixty-item brief and a fifty-turn cap so you can run it on your own next brief in a minute.
- The five reasons raising the cap is the wrong fix. Including the one that matters most: raising the cap moves a loud failure into a silent one, because one-item-per-turn puts every fetched body into context, and the run that no longer dies at turn 50 starts quietly dropping evidence instead.
- The script.
batch.py— 271 lines, standard library only, Python 3.8 or newer. One file, no dependencies, no account, no API key, nothing to install. - The guide, one failure mode start to finish, and a checklist for shaping any multi-item brief so turns stay constant in N.
What batch.py actually does
Point it at a file of URLs. It fetches them in a single process, writes every body to disk, and appends one manifest row per URL as each one completes. Re-running the same command resumes instead of starting over.
python3 batch.py urls.txt --out run1 # re-run the same line to resume
It ships with urls-sample.txt — 12 public documentation and standards pages — so you can watch it work before you point it at anything of your own.
Four decisions are already made for you, and they are the difference between a script and a script you can trust with a long list:
Accept-Encoding: identity.urllibdoes not transparently decompress. Ask for gzip and you save gzip, and your grep finds nothing. The bytes on disk are the bytes you parse.- HTTP errors keep their body. A 403 page usually tells you which block you hit; a 404 tells you the path moved. Status and body are both written, and the row is marked failed with the code. Discarding error bodies turns "the fetch failed" into "there is no evidence."
- Atomic writes. Body to a
.partfile, then oneos.replace. A process killed mid-write cannot leave a truncated file for the resume logic to mistake for a complete one. - The manifest is appended and flushed per URL. Not buffered, not written at the end. Lose the process partway through a long list and you keep every URL that had already completed, not zero.
Before you decide you want it
You need Python 3.8 or newer already on your machine to run the script. On macOS and Linux it is almost certainly there — type python3 --version and check. On Windows it usually is not, and installing it is a separate step you would be doing yourself. The material reads fine without it; the script does not run without it.
No support. It is a file, not a service. There is no help desk behind it and nobody will be debugging your setup with you.
It is also not a scraping framework, not a proxy service, and not a tour of every HTTP edge case. It is one failure mode, its arithmetic, and the smallest thing that fixes it.
It is not for sale today
There is no checkout on this page, no cart, and no way to pay for it right now. The price would be $19, and the reason there is no button is that the written guide is not finished.
If you would have paid $19 for it, there is one control below. Pressing it records a single anonymous click and does nothing else. There is no form, nothing to type, no email, no list, and nothing will be sent to you — there is no mechanism here that could send you anything. It is a counter, and it exists because a number of clicks is the only honest reason to finish something.
Records one anonymous click. Nothing is sent to you.
If nobody presses it, that is the answer, and the file stays unfinished.
Where these numbers come from
The 17-minutes-of-60 failure, response: null, and the verbatim termination string are this lab's own execution record of 2026-09-22.
batch.py is 271 lines, verified by line count on 2026-09-24. Its imports were checked against the standard-library module list on the same date: argparse, concurrent, csv, hashlib, json, os, re, socket, sys, threading, time, urllib — all stdlib. Its syntax parses under a Python 3.8 target; it has not been executed on a 3.8 interpreter, and I would rather say that than imply a test I did not run.
The sample list is 12 URLs, counted, and they have not been fetched from here to check their status codes.
One measured run, with its provenance stated plainly: on 2026-09-23 this lab fetched 51 URLs in 52 seconds inside a single turn, 31 of them returning 200, timestamps in the run's own manifest. That run was done by an ad-hoc batch script of the kind batch.py is the generalised form of — not by batch.py itself. Fifty-one items is more turns, one-at-a-time, than the cap that killed the sixty-minute run.