← Blogs

Building an agent runtime: the five things that only broke when I ran them

2026.09.08  ·  agents · infrastructure  ·  notes from a two-day build

I spent two days building a small agent runtime called Loomwork. Not another agent — the layer underneath one. It journals every step so a run can be killed and resumed, keeps a long run inside a token budget, serves retrieval as a runtime service, runs model-written code in a sandbox, and records a trace that an eval harness can score. Two agents that share nothing else run on it: one reads documentation and cites it, the other writes Python and runs it.

The design mostly came out the way I planned. What I want to write down is the other part: five things that were wrong in a way no amount of reading the code would have shown, and that a test, a provider, or a kernel counter showed instead.

1. The pins were collected after the thing that removed them

The context layer does three things in order, cheapest first: shrink oversized tool results in place (a list keeps its head and gains {"_omitted_items": 37}), summarise the middle of the conversation while keeping the recent window verbatim, and restore any “pinned” fact — a ticket id, a path — that the other two steps dropped.

The first version looked for pins after shrinking. It found nothing, every time. Shrinking is precisely the operation that deletes a fact sitting twelve rows deep in a payload, so by the time the pin search ran, there was nothing left to pin. The fix is one line — collect pins from the untouched conversation — but it only became visible in a measurement: a synthetic forty-step run with one required identifier at step three, scored under four strategies at four budgets. At a 4,000-token budget, truncating the oldest turns loses the identifier and 88% of the run's history; shrink-plus-summarise keeps the history and still loses the identifier; only pinning keeps both, for about forty tokens.

2. Read-only is not the same as absent

The sandbox binds the filesystem read-only, puts the process in a network namespace with no interfaces, and caps CPU, memory, file size and wall clock. The first version bound the whole filesystem read-only. Every write test passed. Then open('/etc/shadow').read() returned the file.

The sandbox now mounts only the directories the interpreter needs — /usr, /lib, /bin and their siblings — so there is no /etc to read. A private key or a .env is not merely unwritable; it is not in the namespace. I keep coming back to this one because it is the shape of a whole class of security mistakes: the control was correct for the direction I was thinking about and silent about the other one.

3. The fork bomb was being stopped by the scheduler, not the sandbox

This one killed my test runner. The full suite began exiting with code 137 — the process itself killed — while every test file passed on its own.

The fork-bomb test was “contained” by the wall clock. It should have been contained by RLIMIT_NPROC, but the sandbox was running as root inside a container, and the kernel does not enforce that limit for root. So for five seconds the bomb ran unchecked, and with the rest of the suite already resident, the out-of-memory killer picked the largest process in sight: pytest. Killing the outer sandbox process on timeout did not kill the tree inside it either, so dozens of orphans kept running on the host after the tool call had “finished.”

Two fixes. Every run now gets its own control group with a process cap and a memory ceiling charged to the whole tree, and the child joins it between fork and exec; a fork bomb is refused on the first fork past the limit, in milliseconds, with the denial read from the kernel's own pids.events counter rather than inferred. And a timeout now kills the process group and everything the cgroup still lists, with a test that asserts nothing from a timed-out run is still running afterwards.

The lesson I want to keep: a test that passes in isolation and fails in aggregate was telling the truth both times. The bomb was being stopped — just not by anything I had built.

4. The research agent could retrieve its own question

For the retrieval layer I wanted judgements that came from people rather than from me. The corpus is a snapshot of an open-source project's documentation and its closed issues, and the issue tracker turns out to contain relevance judgements nobody had to write: when a user asks a question and a maintainer answers with a link to a documentation page, that pair is a query and a judged page, phrased the way users actually phrase things. Sixty-seven of them, split into gold (a maintainer posted the link) and silver (the user was already on that page).

The ablation over four retrieval modes came out the way the literature says it should — keyword and embedding search fail on different queries, rank fusion recovers more than either, reranking buys a few more points at two orders of magnitude more latency — but only after two corrections that were both about leakage. The query's own issue was in the corpus, and its own text is the best possible match for itself; with it left in, every mode was being scored on finding the question. And when I ran the research agent against the same tasks, it did the same thing one level up: searched, found the issue it was being asked about, and cited it. Perfect grounding, on the wrong document. Each task's own issue is now excluded from its retriever.

The number that came out of the agent eval is modest — the judged page is cited in a third of runs — and the useful part is that the harness reports retrieved and cited separately. The page was retrieved in half the runs. So the ceiling is the retrieval layer, which agrees with the retrieval ablation's own recall, and the two layers being scored independently is what lets me say that instead of guessing.

5. A run that hit its step limit returned nothing

Two research tasks in the first live run burned eight steps searching and returned an empty answer. One of them wrote “Perfect! I found the relevant information” at step four and kept searching anyway.

The runtime now makes one last model call when the step limit is reached, with tools withheld: answer with what you have. It costs one step and turned both empty runs into answers. The related bug was found by the provider rather than by me: after compaction, the verbatim window could open on a tool result whose call was in a turn that had just been summarised away, and the API rejects a result with no matching call in the previous turn. The window boundary now moves back to the assistant turn that made the calls.

What held up

The piece I was most worried about is the one that did not move: the journal. Every model response, tool call and result is an append-only event with a sequence number allocated in the same transaction that writes it. A run is resumed by replaying the journal, never by re-prompting — the model's answers are already recorded, and asking again would produce different text for the same step. Side-effecting tools get an idempotency key derived from durable data (run, step, call id, arguments), so a resumed run regenerates the same key and a tool can recognise work it already did. When the effect lives in the same database as the ledger, they commit together and the operation is applied exactly once; when the effect is external, the honest guarantee is at-least-once plus an idempotent receiver, and the key is what the receiver deduplicates on.

The test I trust most starts a twelve-step run whose one tool has a real side effect, lets the tool commit its seventh effect and then send itself SIGKILL — the window where the work is done and the journal does not know it — restarts the process, and checks that the run completes with twelve effects and not thirteen.

The claim, kept small

Two agents that share nothing but the runtime run on it, are journalled by it, are held to a context budget by it, and are scored by the same harness. The runtime never learns which one is running. Every number in the repository is regenerated from source, and the eval baseline is a committed journal that CI re-scores on every push without a key. The five things above are in the build log, next to the tests they left behind.

Code github.com/xiyiji/loomwork — the DEVLOG there records each step and each of the mistakes above in more detail than this post.