The conversation about AI agents has gotten very large very quickly. Thousand-agent swarms. Commit DAGs. Knowledge graphs as shared memory. Orchestrators writing orchestrators. It is genuinely interesting, and it is also very easy to read a month of it and end up knowing the vocabulary without having built anything.
So this post does two things. First, the map — five architectures and what each one actually buys you, compressed to the part you'd use in a design review. Then the antidote: a small experiment you can run on your own machine this afternoon that makes the whole thing concrete. Under 180 lines across five files, no GPU, no API keys beyond a Claude Code install you already have. Every number in this post is from my own run, which took 25 minutes and ended at a 57% improvement over the baseline.
Five Architectures, Five Bottlenecks
The useful framing is that none of these are competing products. Each one takes a different thing out of the model's context window and puts it somewhere durable. That's the whole idea:
ARCHITECTURE WHAT IT MOVES OUT OF CONTEXT CANONICAL FORM
------------------------------------------------------------------------
Loop iteration + evaluation Karpathy's autoresearch
Chain task order prompt chaining
Swarm parallel search, specialisation fan-out sub-agents
DAG experiment lineage a commit graph
Knowledge graph shared facts + provenance typed entities/relations
Read down that middle column and the progression stops being a hype ladder. A loop remembers the current attempt and its score. A swarm creates many independent contexts so no single one has to hold everything. A DAG remembers which work descended from which experiment. A graph remembers which claims connect to which entities and sources. Each is an answer to "what did we forget last time, and where should it have lived instead?"
This also tells you the failure mode. If you add agents without moving anything out of the context window, you haven't built a system — you've built the same system, more expensively, with more surface area for confident nonsense.
The Test That Matters More Than the Map
Before any of the architecture, there's a four-part test for whether a task should be automated at all. Karpathy's autoresearch works because ML training satisfies all four:
- The output is verifiable. A training run produces a number. Not a vibe, a number.
- The action is reversible.
git resetreturns you to the last good state, always. - The horizon is short. Five-minute runs mean frequent feedback, which means errors get caught while they're still cheap.
- The environment is bounded. One editable file. The agent cannot wander.
Most tasks people want to automate fail at least one of these, usually the first. If you can't say what "better" means as a number or a passing test, the honest move is to go build that measurement and come back — not to add a second agent to check the first one's work. Two agents with no metric produce two opinions.
That's also why this is the same conversation as evals. A loop is just an eval you're allowed to optimise against.
The Experiment: A Tiny Autoresearch Loop
Here's the setup. We give an agent a character-level language model, a fixed metric (bits per character on held-out text — lower is better), and permission to edit exactly one file. Then we let it run experiments on its own, keeping the changes that improve the metric and reverting the ones that don't.
It's the same shape as Karpathy's harness, shrunk until each experiment takes well under a second instead of five minutes, so you can watch the ratchet climb in real time instead of overnight. Five files.
Setup
mkdir autoresearch-mini && cd autoresearch-mini
curl -sSL -o input.txt \
https://raw.githubusercontent.com/karpathy/char-rnn/master/data/tinyshakespeare/input.txt
printf 'input.txt\n__pycache__/\n' > .gitignore
touch results.jsonl
1. prepare.py — the protected harness
This file is the referee, and the agent is forbidden from touching it. It owns the data split, the metric, and the sanity checks. Everything that makes the experiment trustworthy lives here.
"""Fixed evaluation harness. The agent MUST NOT modify this file."""
import importlib, json, math, sys, time
CONTEXT = 16 # characters of history the model may see
VAL_CHARS = 30000 # keep an iteration fast
TIME_LIMIT = 120.0 # a change that blows this counts as a crash
def load():
text = open("input.txt", encoding="utf-8").read()
vocab = sorted(set(text))
n = len(text)
train = text[: int(0.90 * n)]
val = text[int(0.90 * n) : int(0.90 * n) + VAL_CHARS]
test = text[int(0.95 * n) : int(0.95 * n) + VAL_CHARS]
return train, val, test, vocab
def bits_per_char(model, text, vocab):
stoi = {c: i for i, c in enumerate(vocab)}
total = 0.0
for i, ch in enumerate(text):
dist = model.next_dist(text[max(0, i - CONTEXT) : i])
p = float(dist[stoi[ch]])
if not math.isfinite(p) or p < 0:
raise ValueError(f"invalid probability {p} at position {i}")
total += -math.log2(max(p, 1e-12))
return total / len(text)
def main():
split = "test" if "--test" in sys.argv else "val"
train_text, val, test, vocab = load()
t0 = time.time()
mod = importlib.import_module("train")
model = mod.train(train_text, vocab)
train_secs = time.time() - t0
# A distribution must be a real distribution, not a pile of large numbers.
probe = model.next_dist(train_text[:CONTEXT])
if abs(float(sum(probe)) - 1.0) > 1e-4:
raise ValueError(f"next_dist must sum to 1.0, got {float(sum(probe))}")
if len(probe) != len(vocab):
raise ValueError(f"next_dist must have {len(vocab)} entries")
bpc = bits_per_char(model, test if split == "test" else val, vocab)
elapsed = time.time() - t0
if elapsed > TIME_LIMIT:
raise TimeoutError(f"run took {elapsed:.1f}s, limit is {TIME_LIMIT}s")
print(json.dumps({"split": split, "bpc": round(bpc, 4),
"train_secs": round(train_secs, 1),
"total_secs": round(elapsed, 1)}))
if __name__ == "__main__":
main()
Note what those checks are doing. The distribution must sum to 1, so the agent can't win by returning enormous unnormalised numbers. The model only ever receives the training slice, so it can't peek at validation data. There's a time limit, so "make it better" can't quietly become "make it take four hours." Every one of those guards exists because it is the shortest path to a better score, and an optimiser with no conscience will find the shortest path.
2. train.py — the mutable surface
The entire experimental surface, deliberately starting from something bad: a unigram model that ignores context completely and just returns overall character frequencies.
"""The experimental surface. This is the ONLY file the agent may edit.
Contract:
train(text, vocab) -> model
model.next_dist(context) -> numpy array of len(vocab) probabilities
summing to 1
`context` is up to 16 characters of preceding text (empty at the start).
"""
import numpy as np
class Model:
def __init__(self, probs):
self.probs = probs
def next_dist(self, context):
return self.probs
def train(text, vocab):
stoi = {c: i for i, c in enumerate(vocab)}
counts = np.ones(len(vocab), dtype=np.float64) # add-one smoothing
for ch in text:
counts[stoi[ch]] += 1
return Model(counts / counts.sum())
That scores 4.8268 bits per character. Uniform random guessing over this vocabulary would be about 6, so the baseline is barely better than nothing. Which is the point — you want visible headroom.
3. program.md — programming the program
This is the most interesting file, and the one worth stealing regardless of whether you run the rest. It's not a prompt so much as a policy document for an autonomous process: what's mutable, what the metric is, what counts as cheating, when to keep, when to revert, when to stop.
# Research program
You are an autonomous ML researcher. You run ONE experiment, then exit.
Do not ask the human anything.
## Objective
Minimise `val_bpc` — the bits-per-character of `train.py`'s model on the
validation split. Lower is better. The baseline unigram model scores 4.83.
## Files
- `prepare.py` — PROTECTED. Never edit it, never work around it.
- `input.txt` — PROTECTED. Never edit it.
- `train.py` — MUTABLE. This is your entire experimental surface.
- `results.jsonl` — the experiment log. Append one line per experiment.
## Rules
1. The model may only learn from the `text` argument passed to `train()`.
Never open `input.txt` yourself, never touch the val or test slices.
Reading the evaluation data is cheating and invalidates the whole run.
2. `next_dist(context)` must return `len(vocab)` probabilities summing to 1.
It sees at most 16 characters of history.
3. Training + evaluation must finish inside 120 seconds.
4. One change per experiment. If you change three things and the score
moves, you have learned nothing about which one worked.
## The loop — do exactly this, once
1. Read `train.py` and the last 10 lines of `results.jsonl`.
2. Form ONE hypothesis that the log does not already refute. Prefer ideas
adjacent to what has recently worked; if the last 3 experiments all
failed, change direction rather than tuning the same knob again.
3. Edit `train.py` to implement it.
4. Run `python3 prepare.py`. It prints JSON with `bpc`.
5. Decide:
- **Crash or timeout**: fix it if the bug is mechanical and obvious,
otherwise `git checkout -- train.py` and record it as a crash.
- **bpc lower than the best in the log**: keep it.
`git add -A && git commit -m "<hypothesis> — bpc <score>"`
- **bpc not lower**: `git checkout -- train.py`. The change is
discarded, but the result is still knowledge — record it.
6. Append exactly one line to `results.jsonl`:
`{"n": <int>, "hypothesis": "<one sentence>", "bpc": <float|null>,
"status": "kept"|"reverted"|"crashed", "note": "<what you learned>"}`
Commit that line too, so the log survives the revert.
7. Exit. Do not start a second experiment.
## Ideas worth trying (not exhaustive, not in order)
Higher-order n-grams. Backoff or interpolation between orders. Tuning the
smoothing constant. Weighting contexts by how often they were seen. Caching
recent characters. Anything else you can justify.
## When to stop
If the log shows 8 consecutive experiments with no improvement, append a
final line with `"status": "exhausted"` and stop proposing changes.
4. loop.sh — the loop itself
Anticlimactically small. One claude -p call is one experiment, with a completely fresh context every time.
#!/usr/bin/env bash
set -u
N=${1:-15}
for i in $(seq 1 "$N"); do
echo "=== experiment $i/$N ==="
claude -p "Read program.md and follow it for exactly one experiment." \
--permission-mode acceptEdits \
--allowedTools "Read,Edit,Write,Bash(python3:*),Bash(git:*),Bash(tail:*),Bash(cat:*)" \
2>&1 | tail -5
echo
done
python3 plot.py
5. plot.py — read the ratchet
"""Print the ratchet: every experiment, and the best-so-far line under it."""
import json
rows = [json.loads(l) for l in open("results.jsonl") if l.strip()]
best = float("inf")
print(f"{'#':>3} {'bpc':>7} {'best':>7} status hypothesis")
print("-" * 78)
for r in rows:
bpc = r.get("bpc")
if bpc is not None and bpc < best:
best = bpc
mark = {"kept": "✓", "reverted": "·", "crashed": "✗"}.get(r["status"], " ")
shown = f"{bpc:.4f}" if bpc is not None else " -- "
print(f"{r['n']:>3} {shown:>7} {best:>7.4f} {mark} "
f"{r['status']:<9} {r['hypothesis'][:44]}")
print("-" * 78)
kept = sum(1 for r in rows if r["status"] == "kept")
print(f"{len(rows)} experiments, {kept} kept, baseline 4.8268 -> best {best:.4f}")
Then commit the baseline and start it:
git init -q && git add -A && git commit -q -m "baseline: unigram, bpc 4.8268"
chmod +x loop.sh
./loop.sh 15
What Actually Happened
Thirteen experiments, twenty-five minutes of wall clock, about 1.9 minutes per experiment. I didn't touch it after starting it. Here's the log, hypotheses shortened to fit:
# bpc best status hypothesis
------------------------------------------------------------------------------
1 2.2445 2.2445 ✓ kept order-5 n-gram, Witten-Bell interpolation
2 2.6247 2.2445 · reverted extend to order 8
3 2.2712 2.2445 · reverted absolute discounting, D=0.75
4 2.2992 2.2445 · reverted per-order discount via Ney's formula
5 2.1195 2.1195 ✓ kept Kneser-Ney continuation counts, low orders
6 2.3629 2.1195 · reverted retry order 8 now that low orders differ
7 2.0835 2.0835 ✓ kept scale escape weight, LAMBDA=2
8 2.0993 2.0835 · reverted push it further, LAMBDA=3
9 2.0892 2.0835 · reverted per-order LAMBDA ramp, increasing
10 2.0825 2.0825 ✓ kept same ramp, decreasing instead
11 2.0806 2.0806 ✓ kept escape scale conditional on context count
12 2.0803 2.0803 ✓ kept sweep that knob wider
13 2.3370 2.0803 · reverted continuation counts at the top order too
------------------------------------------------------------------------------
13 experiments, 6 kept, baseline 4.8268 -> best 2.0803 (57% reduction)
No crashes, which surprised me — I'd expected at least a couple of shape errors against the next_dist contract. Seven of the thirteen were reverted. That ratio is the system working, not failing.
The git history is worth looking at separately, because it's the point the paper makes about DAGs, made concrete:
$ git log --oneline
931bf8d exp 13: top-order continuation counts, bpc 2.337, reverted
c0f4e2a exp 12: LAMBDA_SINGLE 4 -> 8, bpc 2.0803
...
0162274 exp 4: Ney-estimated per-order discount, bpc 2.2992, reverted
d23edc2 exp 3: absolute discounting D=0.75, bpc 2.2712, reverted
cbcd558 exp 2: order-8 Witten-Bell, bpc 2.6247, reverted
1622558 order-5 Witten-Bell interpolated n-gram — bpc 2.2445
bc70b25 baseline: unigram, bpc 4.8268
Every experiment is committed, including the failures — train.py only changes on a keep, but the log line lands either way. The history is the experiment record. You don't build a database for this; you already have one.
Six Things the Experiment Teaches That Reading Doesn't
1. The harness is the work. The agent is the easy part. Nearly all of my design time went into prepare.py and program.md — the normalisation check, the time limit, the train-only data split, the rule about one change per experiment. The thing that actually drives the loop is ten lines of bash. Reading about agent architectures leaves you thinking the intelligence is the hard bit. Building one makes it obvious that the referee is.
2. A fresh context every iteration is a feature, not a limitation. Each claude -p call starts blank. It knows nothing about the previous twelve experiments except what it reads from results.jsonl and git log. That is the entire "externalise memory" thesis, implemented as a for-loop. And it means the loop doesn't degrade as it runs — there's no transcript accumulating, so experiment 50 works exactly as well as experiment 2. Long-running agents don't get long contexts. They get good filesystems.
3. The revert is the whole safety property. Not the model's judgment — the ratchet. Experiment 2 was a perfectly sensible idea (the log said context depth was still paying off, so go deeper) and it was 17% worse. It got thrown away without a human present. The guarantee autonomy actually needs isn't "the agent is smart," it's "the agent cannot leave the repo worse than the last commit." That is a property of the harness, and you get it for free from git.
4. The reverted experiments are where the wins come from. This is the one I'd have skimmed past in the paper, and it was the most striking thing to watch. Experiments 2, 3 and 4 all failed — deeper context, then two flavours of absolute discounting. Then experiment 5 read those three failures, concluded that the problem was what was being counted rather than how it was smoothed, switched to continuation counts, and got the second-biggest win of the run. Same pattern later: experiment 9's ramp went the wrong way, so experiment 10 reversed it and kept it.
A human researcher holds that reasoning in working memory and mostly doesn't write it down. Here it's in a JSONL file, which is why an agent with total amnesia could pick it up. The note field ended up mattering more than the score.
5. Returns collapse much faster than the narrative suggests. Look at where the 2.75 bits of improvement actually came from:
experiment 1 94.0% of the total gain
experiment 5 4.6%
experiment 7 1.3%
experiments 10-12 0.1%
One experiment did essentially all the work. The remaining twelve fought over the last 6%, and the final three combined moved the metric by 0.0032 bits — noise wearing a lab coat. "700 experiments, 20 optimisations" sounds like a steady drip of discovery; in practice the shape is one cliff and a long gentle slope. Budget for that. The value of experiment 40 is nothing like the value of experiment 4, and a loop will happily keep burning tokens on the slope forever because nothing in it knows the difference.
6. The metric drifts away from the truth, and only a second measurement catches it. After thirteen rounds of optimising against the validation split, I scored the final model on the untouched test split:
$ python3 prepare.py
{"split": "val", "bpc": 2.0803, ...}
$ python3 prepare.py --test
{"split": "test", "bpc": 2.1178, ...}
The number the loop was optimising is now about 1.8% optimistic. Nothing cheated — the harness made cheating structurally impossible — the ratchet simply did what ratchets do and absorbed some of the validation set's noise into the model. Note that those last three experiments gained 0.0032 bits against a measurement that's off by 0.0375. They were fitting the referee, not the problem.
This is the single most transferable warning in the whole exercise. A loop improves the metric it can see, with total indifference to whether that metric still means what you thought. Hold something back, check it periodically, and treat the gap as your credibility budget.
When to Go Past a Loop
Once the loop is real, the rest of the ladder stops being abstract, because each rung is an answer to a specific complaint you'll now have felt:
- A swarm when experiments are genuinely independent and you're bored waiting. The requirement people skip: define the reducer before you fan out. If you can't say how twenty results collapse into one decision, twenty workers will just produce twenty results.
- A DAG when you need more than one live branch. Our loop keeps a single best lineage, which quietly assumes the best current score is the best place to build from — often false. Real search wants to resume from a promising dead end.
- A knowledge graph when facts must outlive the run and carry provenance. Not because you have agents — because you have claims that need sources, and questions that span documents. If a table answers every query you have, use the table.
And the honest counterweight, which the enthusiastic version of this story tends to bury: splitting work across isolated contexts costs quality on anything tightly coupled. Architecture decisions, subtle product judgment, refactors that touch everything — those want one context that holds the whole problem. Parallelism is for search, not for thinking.
Where This Lands at Work
The thing I'd actually take into a real codebase isn't the n-gram model. It's the harness shape: a protected referee, one mutable surface, a number that goes down, and a log that survives the revert. Swap the metric and you have an optimiser for prompts against an eval set, for query latency against a benchmark, for bundle size, for flaky-test count, for anything you've bothered to measure.
Which puts the work back where it always was. The agent is the easy part now. The hard part is being able to say, precisely enough that a machine can check it, what "better" means — and building the referee that can't be talked out of it.
If you want the layer underneath this, A Dummy's Guide to Evals covers how to build the measurement in the first place, and Inside ~/.claude covers where Claude Code keeps the state that makes loops like this reproducible. But start with the loop. Twenty minutes of watching a metric fall on its own teaches more than another month of reading about swarms.
~ Comments & Discussion ~
Have thoughts on this post? Join the discussion below! Comments are powered by Disqus.