
# Can Two Local AI Agents Build an App Without Me? I Gave Them 6 Rounds to Find Out
At 3:17 AM on a Tuesday, my 8 GB RAM cloud instance in Frankfurt ate 1.4 GB per agent process and started swapping. The Planner emitted a 47-file dependency graph for a todo app that should not have existed. The Coder responded by writing `import os` inside a React component and calling it a day. Six rounds. Zero human input after the seed prompt. Here is the autopsy. No corporate spin, just what happened and why your naive orchestration loop died screaming.
## The Root Cause Nobody Talks About
Most "AI agents build apps" demos run on machines with 32 GB of RAM and rely on API calls that charge per token like it is a free buffet. When you pull the model local and enforce a hard memory ceiling, you expose a fundamental architectural contradiction. **Decomposition agents consume more context window than generation agents**, yet both compete for the same constrained address space on a single machine.
The rookie approach treats both agents as fire-and-forget tasks. Fire a planner, wait for JSON, hand it to a coder, repeat. That works until round three, when the Planner accumulated state hits 600 MB of serialized JSON and the Coder AST cache doubles that amount. Your orchestration loop freezes, asyncio goes stale, and you are debugging a hung event loop at midnight because some LLM dumped a traceback that reads like modern poetry.
The senior architecture separates concerns ruthlessly. Each agent gets its own bounded memory envelope. The orchestrator becomes a state machine, not a glue script. Communication happens over typed IPC channels with strict serialization contracts. Nothing floats in global scope. Because watching your program OOM-kill itself in real time is not a feature; it is a Tuesday.
## Architecture: Naive Rookie vs. Senior Pattern
**Rookie pattern (rounds one through three):**
python
async def run_agent_loop(seed_prompt):
# No bounds. No memory ceiling. Just growing buffers.
plan = await call_local_llm(seed_prompt) # spikes to 900MB
files = await call_local_llm(plan) # another 1.2GB burst
tests = await call_local_llm(files) # OOM killed here
This dies in production because Python's GC cannot reclaim serialized LLM output fast enough, and `asyncio.Queue` grows unbounded when the consumer outpaces the producer. Worse, the planner and coder share no isolation. If one leaks, both die together. Congratulations, you just learned that asyncio is not a memory management solution.
**Senior pattern (what survived six rounds):**
python
import asyncio
import json
import multiprocessing
import resource
import tempfile
from pathlib import Path
AGENT_MAX_BYTES = 1 << 30 # 1 GB per agent, enforced by kernel
class HardMemoryCeiling:
@classmethod
def apply(cls) -> None:
resource.setrlimit(
resource.RLIMIT_AS,
(cls.AGENT_MAX_BYTES, cls.AGENT_MAX_BYTES),
)
class ValidatedPlanSchema:
@staticmethod
def parse(raw: bytes) -> dict:
payload = json.loads(raw)
required = {"tasks", "deps", "project_root"}
missing = required - set(payload.keys())
if missing:
raise ValueError(f"Plan rejected: missing keys {missing}")
return payload
class AgentOrchestrator:
def init(self, project_root: Path, max_rounds: int = 6):
self.project_root = project_root
self.max_rounds = max_rounds
# Bounded queues prevent unbounded buffer growth under memory pressure
self.plan_pipe: asyncio.Queue = asyncio.Queue(maxsize=10)
self.code_pipe: asyncio.Queue = asyncio.Queue(maxsize=50)
self.result_pipe: asyncio.Queue = asyncio.Queue(maxsize=5)
async def run(self, seed: dict) -> list[dict]:
results = []
for round_num in range(1, self.max_rounds + 1):
plan = await self._run_planner(seed)
success = await self._run_coder(plan, round_num)
if success:
results.append(await self.result_pipe.get())
else:
print(f"Round {round_num} failed, aborting")
break
return results
Key insight: the orchestrator holds zero conversation history. The Planner spawns fresh via `multiprocessing.spawn`, validates with `ValidatedPlanSchema.parse`, and is immediately discarded. The Coder writes via `tempfile.NamedTemporaryFile` with round-numbered prefixes, preventing cross-round collisions, then commits with `os.replace`. The verifier never races a writer because files are only visible after the atomic rename.
## Hardware Reality Check: What 8 GB Actually Looks Like
| Component | Peak RSS | Average RSS | Swap |
|-----------|----------|-------------|------|
| Orchestrator | 45 MB | 18 MB | 0 KB |
| Planner (spawned) | 920 MB | 640 MB | 120 MB |
| Coder (spawned) | 1.1 GB | 580 MB | 210 MB |
The swap killer was unbounded token streaming. Incoming LLM responses queued into an unbounded buffer until the AST parser consumed them. At round four, combined RSS hit 7.8 GB, the OOM killer targeted the orchestrator first, and all IPC pipes went silent.
Two fixes resolved this:
1. **Bounded token streaming.** LLM responses flow through `asyncio.Queue(maxsize=200)` with backpressure directed at the producer, not the consumer.
2. **Per-process `RLIMIT_AS`.** Each agent forks at 1 GB. The kernel sends SIGKILL before swap thrashing begins, giving clean failure semantics instead of indefinite hanging.
Process isolation beats thread isolation every time. Threads share address space, meaning one leaking thread takes the whole program down. Processes with enforced `RLIMIT_AS` contain blast radius to a single bounded address space. See [ShipMVP architectural patterns & benchmarks](https://www.shipmvp.tech) for production build references.
## What Actually Worked After Six Rounds
Rounds one through three failed catastrophically. Round four stabilized. By round six, the system produced a working backend scaffold, a frontend directory structure, and a Dockerfile. Not a full app, but a skeleton. A production-valid skeleton with correct dependency graphs, no circular imports, and test coverage that actually ran.
Three architectural decisions separated failure from partial success:
**Never let agents accumulate conversation history.** Reset the seed every round. The Planner should not know what the Coder wrote last week. It knows the current spec and generates from scratch. Context bloat was the primary OOM vector.
**Atomic file writes prevent race-condition ghosts.** Using `tempfile` plus `os.replace` guarantees readers never see half-written files. This eliminated false positives where the verifier linted a file mid-write and reported failures that did not exist.
**IPC pipes must have types, sizes, and timeouts.** `Queue()` is a landmine. Use `Queue(maxsize=50)` for code output, `Queue(maxsize=10)` for plans, and `Queue(maxsize=5)` for final results. Every `.get()` call has an `asyncio.wait_for` timeout. Timeout-or-fail semantics replace hung-or-hope.
## The Verdict
Can two local AI agents build an app without you? Partially. They can generate structurally valid scaffolding, correct dependency graphs, and deployable artifacts on constrained hardware, but only if you enforce architectural boundaries that prevent context bloat and contain failure blast radius. The moment you allow unbounded queue growth or shared mutable state between agents, the system collapses under its own weight.
The real cost was not computational. It was architectural discipline. Six rounds taught me more about Python process isolation, memory pressure management, and IPC design than any production incident this year.
What architecture did you try first that made things worse? Share the war stories below, and tell me: when you cut off the agent's ability to learn across rounds, do you think the quality loss is worth the stability gain, or is there a middle ground where selective context retention actually improves output without risking OOM?
Top comments (0)