An agent run is a fold over an event log
Agent frameworks keep growing control-flow vocabulary: graphs, subgraphs, supersteps, conditional edges, checkpointers, command objects. Meanwhile the thing an agent actually does has two moving parts: a model decides, and an executor acts. Everything else is bookkeeping, and I want to argue that the right data structure for that bookkeeping is not a graph. It is an append-only log, folded.
A quick word on "fold", since the title leans on it. A fold is the functional-programming name for something you already do: take a list, run it through a function one item at a time, accumulate a result. Most languages call it reduce. Your bank balance is a fold over your transactions. I'm going to argue that an agent's state is a fold over its events, and that once you see it that way, a bunch of hard problems stop being problems.
This is the architecture note for drangue, though the idea is much older than agents: it is event sourcing, applied to the agent loop. Most of what production agents need (durability, audit, human-in-the-loop, distribution) turns out to be a consequence of that one choice.
The split
Divide the loop into two components with opposite temperaments:
- The orchestrator decides. Given the state of the run so far, what is the next step: call the model? run this tool call? request an approval? finish? It is a pure function: no clock, no randomness, no I/O. Same state in, same decision out, every time, forever.
- The executor acts. It calls the model, runs the tool, records what came back. It is where all the non-determinism and all the side effects live.
Between them sits the log. Every step the executor performs becomes an event (model_decision, tool_result, approval_requested, approval_granted, run_finished), appended, never mutated:
@dataclass
class Event:
seq: int # position in the log
type: str
payload: dict
ts: float | None # recorded as a fact; never read during replay
And the run's state is never stored anywhere. When you need it, you compute it:
state = fold(events)
Everything else in this post follows from that line. The log is the source of truth; state is derived, disposable, and can be rebuilt from scratch at any moment, by any process.
The loop
while True:
events = await store.load(run_id) # every fact so far
state = fold(events) # rebuild state from facts
step = orchestrator.next(state) # pure, deterministic
if isinstance(step, Done):
return result_of(state)
if state.result_for(step.seq) is None: # not yet a fact: act, once
key = f"{run_id}:{step.seq}" # stable across retries
recorded = await executor.run(step, idempotency_key=key)
await store.append(run_id, recorded) # append BEFORE moving on
Two details do most of the work here.
Append before you build on it. A step's result lands in the log before the loop continues. If the process dies one line later, nothing is lost: the next process loads the log, folds, and picks up at the first step without a recorded result. Notice there is no separate recovery procedure: every iteration starts by rebuilding state from the log, so recovering from a crash and taking a normal step are the same three lines of code.
Replay reads facts, never re-executes. On resume, recorded model decisions are not re-asked and recorded tool results are not re-run. The model gets to be non-deterministic exactly once (the first time) and is a recorded fact ever after. This is the trick that lets a non-deterministic agent run on infrastructure that demands determinism: when we later put drangue runs on Temporal, the workflow code could be deterministic because all the non-determinism was already quarantined behind the log.
What falls out for free
Here is the claim worth being skeptical of: each of the following is usually a feature, a subsystem, or an entire product. Under the fold, each one is just a consequence.
Durability and crash recovery. Kill the process mid-run; start a new one with the same run_id. It loads the log, folds, and continues. What about a crash in the gap after a tool acts but before its result is appended? That gap is what the idempotency key is for: every side-effecting tool call carries a key built from run_id and seq, so when the retry shows up with the same key, whatever sits downstream (a payment API, an email service) can recognize the repeat and decline to do it twice.
Observability. There is no "add tracing" step. The log is the trace: per-step timing and token usage are event fields, and the span tree (the waterfall view a tracing tool would normally collect for you) is computed from the log after the fact (result.trace in drangue). The distinction matters: a tracer that writes alongside execution can disagree with what actually ran; a trace computed from the execution's own record cannot.
Human-in-the-loop. A gated action is an approval_requested event, and then: nothing. The loop stops, because the orchestrator's next decision needs a fact that isn't in the log yet. The human's decision arrives as an approval_granted or approval_denied event appended from outside the loop entirely (by another process, on another machine, maybe three days later), and resume is just replay. We never implemented a "durable pause" feature; pausing is simply what a fold does when a step is waiting on a person. And because the approval event carries the model's recorded reasoning, the human gets to see why the agent wants to act before saying yes.
Budgets. Token and dollar spend are folds over recorded usage. Enforce before each expensive step; the worst overshoot is the one step already in flight, and the enforcement is auditable because the numbers it read are in the log.
Evals from production. A failed run's log is a complete, replayable specimen. scenario_from_result(result, ...) turns it into a regression test: the eval set grows from what actually went wrong, with no extra capture machinery.
Distribution. If state is a fold of a log in a shared store, then any worker can drive the run. Nothing heavy crosses the wire, just a small spec (run_id, the input, an agent-registry key); the worker rebuilds the agent locally, loads the log, folds, continues. Remote execution and crash recovery turn out to be the same feature too.
What it costs
A bit of self reflecting.. because every architectural decision in software development inherently involves trade-offs. No single pattern or style is universally perfect and adopting one always requires sacrificing something else.
- A write per step. Append-before-continue puts a durable write in the middle of every step. For agent workloads this is noise (steps are dominated by multi-second model calls), but it is not free, and batching is off the table by design.
- Sibling tool calls run serially in drangue today. When one decision requests several tools, each sibling already has its own stable position in the log, which makes running them in parallel feasible. But parallelism interacts with per-tool gates (one sibling may pause for approval while another runs), so we have left it unfinished on purpose rather than getting it subtly wrong.
- Logs grow. Rebuilding a long run means folding every event it ever produced. Not a problem at agent scale (hundreds of events, not millions); snapshotting is the standard escape hatch if it ever is.
- Determinism is a discipline. The orchestrator must not read the clock, roll dice, or touch the network: one violation and replay can diverge from the original run. We hold this with a rule, a conformance test, and replay-divergence tests, because the property is load-bearing for everything above.
The graph I didn't need
None of this requires nodes or edges. The "graph" of an agent (investigate, then decide, then maybe pause, then act) is just the orchestrator's decision function, and a pure function is easier to test, version, and reason about than a data structure that must be compiled, checkpointed, and migrated. Where graph frameworks checkpoint between nodes and replay from node boundaries, the log journals every step and replays facts, and that difference matters most when a pause sits in the middle of something that moves money. (It's a big enough difference that it gets its own blog post, on exactly-once approvals.)
The failure mode this architecture is really defending against is drift: between what ran and what you think ran, between the demo and the incident review, between the agent you shipped and the agent you can explain. The fold's answer is blunt: there is one history, it is append-only, everything else is a view of it.
The whole runtime is small enough to read in a sitting, MIT-licensed, with offline runnable examples (examples/sre/, examples/refunds/) that demonstrate the pause, the replay, and the audit trail with no API key. The loop above is real code: it is engine/eventsourced.py, minus error handling and a couple of optional extras (memory recall, budgets, per-tool autonomy) that hang off the same skeleton.