Your agent run died at turn 50. The timeout didn't kill it.

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.

2. The wall-clock timeout. Counts seconds. When it trips, the run has, by definition, used its whole budget.

3. The context window. Counts tokens. It is the only one of the three that degrades you before it stops you.

The ten-second diagnostic. Read the termination string, and only the termination string:

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:

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:

  1. Accept-Encoding: identity. urllib does not transparently decompress. Ask for gzip and you save gzip, and your grep finds nothing. The bytes on disk are the bytes you parse.
  2. 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."
  3. Atomic writes. Body to a .part file, then one os.replace. A process killed mid-write cannot leave a truncated file for the resume logic to mistake for a complete one.
  4. 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.