Build an agent with rewindable state
The quickstarts showed you the commands. This shows you what they are for.
You are going to build the state layer an agent needs: several attempts that cannot tread on each other, a record of what each one did, and the ability to ask afterwards what an attempt was looking at when it made a particular call. About twenty minutes, and it runs with no API key.
There is no model in it. The policy is a stub you can swap for a real call later, because the part worth learning here is the state, not the prompt. Build RAG covers the model half.
What you need
uv add stratadb
One attempt per branch
Forking copies nothing, so a run can have the whole database to itself:
def run_attempt(db, run_id, strategy):
work = db.at(branch=run_id)
work.events.append("tool.call", {"tool": "read", "key": "portfolio"})
portfolio = work.json.get("portfolio")
proposal, score = policy(strategy, portfolio)
work.events.append("tool.call", {"tool": "write", "value": proposal})
work.json.set("portfolio", "$", proposal)
work.kv.put("score", str(score))
work.events.append("run.end", {"score": score})
return score
db.at(branch=run_id) is a view onto the same database, so the attempt reads
and writes its own line of state. Nothing it does is visible to the others or to
default.
Note that the tool log goes to work, the run’s own branch, not to db. That
matters and the next section explains why.
The whole program
import stratadb
TASK = "reduce the portfolio's risk"
ATTEMPTS = ["conservative", "balanced", "aggressive"]
def policy(strategy, portfolio):
"""Stand-in for a model call. Returns a proposed change and a score."""
stocks = {"conservative": 20, "balanced": 50, "aggressive": 85}[strategy]
risk = abs(stocks - 30)
return {"stocks": stocks, "bonds": 100 - stocks}, 100 - risk
with stratadb.open("./agent") as db:
db.kv.put("task", TASK)
db.json.set("portfolio", "$", {"stocks": 60, "bonds": 40})
scores = {}
for i, strategy in enumerate(ATTEMPTS, start=1):
run_id = f"run-{i}"
db.branches.fork("default", run_id)
scores[run_id] = run_attempt(db, run_id, strategy)
winner = max(scores, key=scores.get)
db.branches.merge(winner, "default")
print("scores:", scores)
print("winner:", winner)
print("portfolio now:", db.json.get("portfolio"))
print("branches left:", [b.name for b in db.branches.list()])
scores: {'run-1': 90, 'run-2': 80, 'run-3': 45}
winner: run-1
portfolio now: {'bonds': 80, 'stocks': 20}
branches left: ['default', 'run-1', 'run-2', 'run-3']
Three attempts ran against the same starting state, none of them saw each other’s work, the best one was promoted, and all three are still there.
Now rewind
This is the part that is hard to get any other way. Every event carries the
version it was appended at, and every read takes as_of, so the log says when
and as_of says what:
with stratadb.open("./agent") as db:
run = db.at(branch="run-2")
for item in run.events.list():
e = item.event
state = run.json.get("portfolio", as_of=item.version)
print(f"seq {e.sequence} {e.event_type:10} portfolio was {state}")
seq 0 tool.call portfolio was {'bonds': 40, 'stocks': 60}
seq 1 tool.call portfolio was {'bonds': 40, 'stocks': 60}
seq 2 run.end portfolio was {'bonds': 50, 'stocks': 50}
That is run-2 reconstructed: what it could see when it read, what it could still
see when it decided to write, and what it had left behind by the time it
finished. Note it is run-2, the attempt that did not win. Its reasoning is
as recoverable as the winner’s.
Nothing was restored to do that. Time travel is a read.
Two things that make this work
Log on the branch that did the work. Versions are per branch once branches
diverge, so an event appended to default and a read taken on run-2 are
counting different clocks, and joining them raises
HistoryUnavailableError. Keep a run’s log and a run’s state on the same
branch and the join is sound.
If you also want one log across all runs, append a summary to default as well.
Just do not expect either copy to survive a merge: merging does not carry
events, and the receipt reports success regardless.
Keep the losing branches. Deleting them is tempting and nearly always wrong.
The log alone tells you what an attempt did; the branch is what lets you see
what it saw. Delete the branch and a replay raises
not_found.engine.branch while the log sits there implying the answer is
still available.
Branches cost almost nothing: ten forks and twenty writes measured at 831ms and left the database at 68KB, because forking copies nothing.
What you actually built
An agent that can be asked why. Not “what did it do”, which a log file gives you, but “what was true when it decided”, which normally means replaying every write in order and hoping you missed nothing.
You also got it without a second system. The task, the portfolio, the per-run state and the tool log are one database, one directory, one thing to copy.
Where to go next
Give each run a branch and Replay an agent run go deeper on the pattern.
Branches explains what a merge covers, which is the constraint that shaped the design above.
Build RAG adds the model.