Skip to content
koopa0.dev
← the work

Bounded autonomy: running an unreliable model unattended

How I run an unreliable model unattended, by putting safety in the wrapper instead of the model.

article· Jul 3, 2026· 0 min

The problem I actually had

I wanted a local agent to take routine work off my hands: translate transcripts, tidy notes into a schema, back up repositories, scan my Go projects for regressions, draft candidate tasks from recent activity. Nothing exotic. The catch is that the thing doing the work is a language model. It is wrong sometimes, confidently. It will invent an API that does not exist, or quietly "improve" a file you never asked it to touch. And it does that unattended, which was the whole point of building it.

Most "autonomous agent" demos answer this by not answering it. They run in a sandbox with a human watching every step, or they run unattended and you find out later what broke. Neither is what I needed. I needed something that runs on a schedule, unattended, and that I can trust the way I trust a cron job: not because it is smart, but because the blast radius of it being wrong is bounded and I can always undo it.

So the design bet is a single sentence: safety lives in the wrapper, not in the model. The worker is probabilistic and I assume it will misbehave. The harness around it is deterministic, and the harness is where all the guarantees come from. This post is about what that harness actually is, with the code paths that enforce it.

The system itself is single-user and local. It runs over a dozen scheduled jobs, many of them plain deterministic scripts with no model in the loop at all; where the model is involved today, it runs as a single-shot step behind a deterministic gate on its output, not as an open-ended agent. The fuller autonomous-reasoning lanes are governed by a launch gate, described below, that was built and tested before anything ran inside it: the cage went up first. None of this is a product or a framework I am selling. It is worth writing up because the discipline is the same one you need to put an LLM into anything that runs without a human in the loop.

Four properties, enforced by the wrapper

The wrapper buys four things, and every routine the agent runs has to sit inside all four:

  • Isolation — a job that edits files does it in a throwaway git worktree on a branch, not on main. If the output is wrong, you delete the branch.
  • Verification — the output has to be checkable by a deterministic script, not by asking another model whether it looks good. If I can't write a checker, it is not a job the agent is allowed to do.
  • Reversibility — every task is graded by how hard it is to undo, and the autonomous lane is capped at "locally reversible." Anything with an external, irreversible effect needs an explicit human approval flag before it can run at all.
  • Visibility — every scheduled run writes a real exit code and duration to an append-only run ledger, so "it ran" and "it did something" are distinguishable after the fact.

None of that is novel on its own. What makes it hold together is that these are not guidelines the agent is asked to follow. They are gates it cannot get past. The operative word is enforced, not asked.

The reversibility axis

Every task the agent can do is tagged with one of four reversibility cells:

R0  read-only
R1  locally reversible        (git worktree, staging, a file I can revert)
R2  externally semi-reversible (a draft posted somewhere I can retract)
R3  externally irreversible    (a merge, a publish, an email that has left)

The autonomous lane is capped at R1. This is one constant in the code (_MAX_AUTONOMOUS_R = 1), and the launch gate reads it: a lane at R2 or above that does not carry an explicit human-approval flag is refused before the first token is generated. R3 (merge, publish, enable, anything that touches my actual accounts or repos) is never automatic, and there is no flag that makes it automatic; those actions happen when I do them.

The point of grading by reversibility, rather than by how much intelligence a task needs, is that undo cost is where the actual risk sits. A wrong-but-reversible action costs you a git branch -D. A correct-looking-but-irreversible action costs you an incident.

The misevolve wall

Here is the part I think is the least obvious and the most important.

There is a fashionable idea that an agent should learn: write to its own memory, refine its own skills, schedule its own follow-up work. "Self-improving agent." I let the agent do a lot. It can fan out subtasks, run code in a sandbox, draft proposals. But there are exactly three tool capabilities it can never have, regardless of how reversible the task claims to be:

ALWAYS_DENY = frozenset({"memory", "cronjob", "send_message"})
  • memory — writing to its own long-term memory. This is the self-rewriting line. An agent that can edit what it believes about you and the world, based on its own judgement, is an agent that can drift, and you will not notice until the drift has compounded.
  • cronjob — scheduling more of itself. An agent that can spawn scheduled work is an agent that can grow its own footprint without anyone deciding it should.
  • send_message — unattended outbound messaging. Nothing leaves to a human or a third party without passing a gate first.

These three are structurally removed from the toolset of any autonomous lane. Not discouraged in a prompt. Absent. The agent can accumulate signal and it can draft a proposed change to its memory; that proposal lands in an inert queue where nothing is trusted until I approve it. What it cannot do is close the loop on itself. Learning, in this system, is a distillation step a human runs. The agent does not get to do it to itself at 3am.

I split the tools deliberately into two sets. The reversible ones (delegating a subtask, running code in a sandbox) are allowed in the reversible lanes, because the yard should be large. The three above are the fence: a hard stop where being wrong is irreversible or self-modifying, latitude everywhere else.

The launch gate: six boundaries, fail-closed

Before any reasoning lane is allowed to start, it passes a deterministic check. The lane is declared as data (which tool set, which reversibility cell, where its output is allowed to land, which gate verifies it), and the checker validates that declaration against six boundaries:

  1. Toolset — the lane must declare a narrow toolset. An empty toolset, or all, or *, is rejected. A full tool surface is treated as no restriction at all.
  2. Wall tools — the three ALWAYS_DENY capabilities (memory, cronjob, send_message) are refused in every lane, no matter what else the lane declares.
  3. Reversible tools — the reversible capabilities (delegating a subtask, executing code in a sandbox) are allowed only in lanes at R1 or below. That holds even for a human-approved R2 lane: approval lets a riskier lane launch; it does not widen the toolset.
  4. Landing — output may only go to an inert destination (a proposal queue, an inbox). The agent cannot write to a place that is treated as trusted.
  5. Reversibility — the R1 cap above, enforced here: a lane at R2 or above without an explicit human-approval flag does not launch.
  6. Gate chain — a verification gate must be declared. Output is pending until the gate passes; it is never automatically trusted.

The checker returns a list of violations. If the list is non-empty, the lane does not launch. This is fail-closed by construction: a malformed lane config does not fall back to a permissive default and quietly run. It raises, loudly, and stops. The scripted (non-reasoning) jobs skip this contract entirely, because they are just scripts with zero autonomy; the contract exists specifically for the lanes where the model gets to reason.

The same fail-closed posture shows up one layer down. When the agent delegates a subtask, the child agent is handed a toolset with the dangerous capabilities structurally stripped: no memory, no send_message, no cronjob, no recursive delegation. That is not a prompt instruction the child might ignore; those tools are not in its schema. And in a scheduled session, an environment marker plus a deny policy means dangerous commands are refused without asking anyone, so there is no approval prompt for the agent to talk its way past.

Determinism where it counts, and the economics that follow

Every gate in this system is deterministic. None of them is "ask a second model whether this looks right." That is a deliberate constraint and it has a reason beyond taste.

The worker runs on a local box: a single A5000 GPU serving a 4-bit-quantized 35B Qwen model (mixture-of-experts, ~3B parameters active per token). Off-site, where the local GPU is unreachable, a small number of high-value lanes fall back to a hosted model; purely mechanical work just skips until the box is reachable again. The consequence of a local model is that generation is effectively free. I can produce as much candidate output as I want.

Which means volume is not the bottleneck; verification throughput is. If the model can draft a hundred proposals a night and I can only check ten, ninety of them are worthless, worse than worthless, because an unverified queue that looks productive is a lie. So the entire system is shaped around a single constraint: never generate faster than a human plus a deterministic gate can verify. That is why the gates are deterministic and cheap. A gate that itself needs a model to judge is a gate you cannot trust and cannot afford to run at volume. And a gate you have learned to ignore is worse than no gate, so every gate has to clear a false-positive bar on real data before it goes live, not just pass its own unit tests.

The harness that enforces all this is about 6,600 lines of Python, standard-library-first by policy: every dependency has to justify its own existence, and the harness is kept deliberately lean. It ships behind 769 tests across 45 test files, and nothing lands unless the full check (unit tests, lint, format, type-check) is green. That rigor is what makes the wrapper trustworthy while the thing inside it is not.

A war story: the hang that was not a hang

Here is a bug, because a system is only as honest as its worst day.

The agent grounds itself against koopa0.dev, a separate production system of mine (a knowledge OS with an MCP server), over HTTP. That server streams responses as server-sent events (text/event-stream): a long-lived connection that dribbles data: lines. My client used urllib with a 25-second socket timeout, and for months it was fine. Then one job started showing bimodal run times: normally about 4 seconds, but two runs took 1038 seconds and 4174 seconds before finishing.

At first it made no sense. That socket timeout was there to catch exactly this kind of stall, and it plainly wasn't doing its job: one of those reads ran for over an hour with the timeout sitting right on top of it. The server was up and answering other callers normally, so the timeout wasn't broken. It was measuring the wrong thing.

A socket timeout does not do what you might assume. It caps a single recv, the wait for the next chunk of data, not the total wall-clock time of the read. Against a streaming endpoint that holds the connection open and sends a trickle of bytes just often enough to reset the socket timer, read() will block essentially forever while never once tripping the 25-second timeout. Every byte arrived inside the window; the total just grew without bound. And because this was in the shared HTTP layer, it did not affect one job. It exposed every caller that talked to that server.

The fix is not "raise the timeout." Raising a timeout that measures the wrong thing gets you a longer wrong thing. The fix is to measure the right thing: read the response in 64KB chunks and check a monotonic wall-clock deadline between chunks, raising past a 90-second wall-timeout. A fully silent connection is still caught by the original 25-second socket timeout; a slow-drip connection is now caught by the wall deadline. Worst case overshoot is one wall-timeout plus one socket timeout, and it is bounded.

Two smaller things came out of the same fix, and they matter more than the timeout. First, the caller that used this path had been swallowing the error, degrading to a silent "write failed" with no reason. Now the reason is written to stderr before the degrade, so a failure is observable in the run ledger instead of being invisible. Second, the repro is a local TCP server that deliberately dribbles bytes, and the test asserts the wall-timeout fires in under a second rather than after 25: a failing test written first, then the fix. Fixing the class, not the instance: this was in the boundary layer, so it was fixed once, at the boundary, for every caller.

None of that is the model being clever. It is the plumbing around the model being correct, observable, and bounded on its worst input. That is where most of the work actually lives.

What it is, and what it is not

This system is a worked example of a specific and increasingly common engineering problem: how do you put a probabilistic component into a position of trust without trusting it? The answer here is not a better model or a cleverer prompt. It is a deterministic harness that isolates, verifies, bounds by reversibility, and stays visible, with one hard wall at self-modification and self-replication, a human as the only decision router, and everything checked before it counts.

That is the discipline I care about, and it is the same discipline whether the agent is tidying my notes or sitting inside someone else's production system.


I take on client work — /hire.