DEV Community

Cover image for Why Does Every AI Agent Still Look Like `while (true) { ... }`?
tom
tom

Posted on

Why Does Every AI Agent Still Look Like `while (true) { ... }`?

Replaces fragile loops with event logs

Open any agent codebase today — Claude Code, Codex, Pi, most of the open-source ones — and you'll find the same skeleton. Something like:

let state = {};
while (true) {
  const plan = await llm.plan(state);
  const results = await runTools(plan);
  state = updateState(state, results);
  if (isDone(state)) break;
}
Enter fullscreen mode Exit fullscreen mode

It's the natural first design. The model is the brain, the loop is the heart, and state is whatever bag of objects you've accumulated. It works great for demos.

But after you start living with one of these agents for a while, the same cracks show up everywhere.


The cracks in the loop

Interruption is a hack. If the user kills the process mid-turn, or a tool hangs, or the model asks for clarification, you've got this awkward half-finished iteration sitting in state. You either throw it away or patch it in place. Either way the state is lying to you about what actually happened.

Retries are special cases. A tool fails? Write a try/catch around it, maybe retry, maybe pass the error to the model, remember to add it to state. After a few months you've got a dozen ad-hoc branches for "what if this turn didn't finish cleanly."

Parallel tool calls are awkward. The model wants to call read and grep at the same time. Now your loop has to either sequence them or spawn promises and reassemble the result before the next llm.plan(). Again, more state to manage.

You can't rewind. state is a pile of mutable objects. You can't branch from an earlier point in the conversation without reconstructing it by hand. You can't replay what the model actually saw. Good luck debugging a long session.

These aren't implementation details. They're the direct result of the while(true) shape: one mutable state object that tries to stand in for the entire history of the conversation.


What if the log is the state?

A few months ago I started building a personal agent, Pizza, with a different premise: the log is the source of truth, and the state is just a projection of that log.

Every message, tool call, result, and file edit becomes one row in an EventStore:

CREATE TABLE events (
  sequence INTEGER PRIMARY KEY,
  event_id TEXT,
  type TEXT,
  payload_json TEXT,
  caused_by TEXT,
  thread_id TEXT,
  ...
);
Enter fullscreen mode Exit fullscreen mode

The runtime doesn't hold state in memory. It reads the tail of this log, dispatches the next event to a handler, and appends the result back. A "turn" is a state transition, not another loop iteration.

The UI, the LLM context, and the session tree are all just queries over the same log. If you want to see what happened, you read the events. If you want to branch the conversation, you start a new thread_id from an earlier sequence. If you want to replay, you re-apply the events.


Other things that fall out of the log

Once you commit to the event log being the source of truth, a bunch of otherwise hard features stop being special cases.

No "new chat" button

There's no hard boundary between tasks. You can keep talking to the same workspace for days, weeks, or years — every previous message, edit, and tool call is still there as an event you can query. The agent manages its own context by projecting the tail of the log, not by asking you to start over with a blank chat. Think of it like a long-running thread with a friend who remembers everything.

One CLI tool, not a JSON menu

Instead of a long list of read_file / write_file / grep / git tools, the model gets one cli tool. Built-in commands like read, write, and edit are handled by structured internal handlers, but everything else — grep, sed, git, npm, python, ls — gets passed straight to the user's shell. The model has to learn shell, but it also gets to compose real pipelines. And because every cli invocation is one event, the log stays consistent: one row for read, one row for git diff, one row for npm test.

Git-like session tree

Because every message is an event with a caused_by pointer, the conversation is already a tree. You can fork from any earlier message, rewind, branch, and continue. It's not a bolt-on undo stack; it's just how the data is shaped.

Same runtime for every interface

The TUI, the desktop app, the JSON-RPC server, and the one-shot CLI all consume the same SessionFacade event stream. They're different projections of the same log. If you start a session in the terminal and later open the desktop app, it's the same event stream.

Agents can tell each other

One Pizza agent in workspace A can send a tell event to an agent in workspace B. Workspace B's agent handles the task in its own event log and writes a result back. The actual project files and context from B never leak into A's log — only the tell request and its response are events.

It can even fix itself (if you enable it)

There's an opt-in skill called pizza-self-optimization that reads the local event log as evidence, forks the Pizza repo, reproduces a bug from the log, writes a test, and opens a PR. It only works because the event log is a reproducible record of what the agent actually did.


The trade-offs nobody wants to admit

Event sourcing isn't free. You now have a real database in the hot path. You have to think about replay cost, log size, and snapshotting. If a session has a few thousand events, rebuilding the context from the log on every fork gets slow; you'll want periodic materialized snapshots. I don't have that yet, but it's clearly the next piece.

You also lose the simplicity of "just keep a big state object in memory." If your runtime needs to know something, it has to be in an event. Anything outside the log is an invisible side effect and a bug waiting to happen.


Not a panacea, but not the only shape either

The while(true) pattern is still the right default for a lot of agents. It's simple, it runs, and it fits in a tutorial. But if you want long-running sessions, branching conversations, multi-agent collaboration, and the ability to audit or replay what the agent actually did, it starts to feel like the wrong abstraction.

The EventStore approach has its own costs, but it makes the hard things — forks, replays, multi-agent collaboration, debugging — into ordinary database operations. That was the bet I made with Pizza, and so far it's the part of the design that's held up best.

If you're curious, the code is open source at github.com/tomsun28/pizza. Feedback and arguments welcome.

Top comments (12)

Collapse
 
reidmarlow profile image
Reid Marlow

Event logs are the part most agent loops rediscover too late. Replay helps, but the bigger win is making interruption a normal event instead of a corrupt half-turn. I’d keep one boring invariant beside it: every tool effect needs an idempotency key, or replay can become a second execution bug.

Collapse
 
anasbuilds997 profile image
anassBld

Spot on breakdown. The standard while (true) loop is the biggest architectural trap in modern agent runtimes because it assumes the execution thread is permanent and all tool invocations are atomic.

The moment you have network timeouts, operator interrupts, or process crashes, an in-memory state object becomes a liability. An append-only event log solves the auditability and replay half of the problem beautifully.

In our agent state machinery, we ran into a critical nuance when pairing event logs with external mutations:

  • Pure reasoning events (planning, schema validation, prompt generation) can be replayed from the log deterministically at zero risk.
  • External mutation events (API POSTs, database writes, CLI actions) cannot simply be replayed on restart, because the previous in-flight process might have already committed the change before dying.

To bridge this, we attach deterministic intent fingerprints to every mutation event before dispatch. If a recovered worker replays the log and sees an unconfirmed external action, it doesn't re-invoke the tool—it enters an explicit reconciliation phase to verify whether the fingerprint already exists on the remote system.

In your event log runtime, how do you handle recovering an in-flight mutation event after a hard crash? Do you rely on provider-side idempotency keys during replay, or do you emit an explicit pending/unconfirmed event that triggers a readback check before the next step?

Collapse
 
publiflow profile image
PubliFlow

Moving from a synchronous polling loop to an event-driven architecture completely changes how you handle state mutations in AI agents. I have found that using an event log not only makes debugging hallucinations easier since you get a strict chronological trail of tool calls, but it also allows you to pause and resume long-running tasks without losing context. The tricky part usually comes down to handling backpressure when the LLM generates events faster than your downstream systems can process them. Have you experimented with how this event log approach scales when multiple agents need to subscribe to the same state changes concurrently?

Collapse
 
eduzsh profile image
Edu Peralta

The cracks you name show up the moment a coding agent leaves the demo and lives in a real session. Mutable state makes an interrupted tool call look like a finished turn, and the next plan then reasons over a lie. Treating the event log as the source of truth flips that: you can ask what the model actually saw, rewind to the last honest sequence, and replay without reconstructing a bag of objects by hand. Parallel tool calls and retries stop being special cases because they are just more events with cause links. The loop is fine for a weekend prototype. It falls apart the moment you need to explain why the agent did something three hours ago.

Collapse
 
eduzsh profile image
Edu Peralta

The event log as source of truth is the bit that held up under real debugging. Mutable loop state is fine until you need to answer what the model actually saw when it made a bad call, and then the bag of objects is already a reconstruction. Treating every tool result as an append only row makes retries, forks, and ignored errors into ordinary queries instead of special cases bolted onto the loop. Curious whether long sessions have forced snapshots yet, because rebuilding context from a long tail is the next tax that design usually pays.

Collapse
 
mnemehq profile image
Theo Valmis

The event log swap is the right fix and it's worth naming why: a while(true) loop treats state as the source of truth, so anything that happens outside a clean iteration, an interrupt, a hung tool, a parallel call, has nowhere honest to go. An event log makes the history the source of truth instead, which is what actually makes rewind and replay possible.

Collapse
 
alexshev profile image
Alex Shev

This is the kind of detail that belongs in an architecture decision record: the tradeoff, the rejected alternative, and the trigger for revisiting it. That context is what keeps future refactors from reintroducing the same problem.

Collapse
 
mickyarun profile image
arun rajkumar

You've rediscovered the ledger, and I mean that as the compliment it is. Payments went through this exact argument a long time ago and landed in the same place: never store the balance, store the entries and derive the balance. A mutable state object is a stored balance. It's a cache of history that has quietly stopped agreeing with history.

The part that made it click for us wasn't rewind or replay, useful as those are. It was that an append-only log makes "what did this thing actually see when it decided" answerable months later, by someone who wasn't there. With a mutable state bag that question has no answer, and in a regulated system "no answer" is itself the finding.

Where I'd push: the log only holds if every side effect is written before it's attempted, not after it returns. Write the entry on the way out and a call that times out leaves no trace of having happened. The one case you built the log for is the one case it's blank.

Collapse
 
subnetica profile image
Subnetica

Is there any kind of access control or anything built-in? Skimmed the codebase and couldn't find any. Seems like it pretty much just passes anything not recognized as a built-in command directly to the shell.

Collapse
 
_hm profile image
Hussein Mahdi

The one cli-to-shell tool is elegant but is also an unbounded RCE surface ; every event is auditable, sure, but auditing after the shell already ran isn't a guardrail.

Some comments may only be visible to logged-in visitors. Sign in to view all comments.