Reversible
DB and file writes run live and undo via the backend's own semantics — a real SQL SAVEPOINT / ROLLBACK TO SAVEPOINT, filesystem copy-on-write. Exact, not best-effort.
Documentation
Wrap your agent's tool calls so the reversible ones roll back and the irreversible ones wait for your yes.
Pherix is a Python library (with a TypeScript SDK) that gives database-style guarantees — atomicity, isolation, capability enforcement, durability — over the external side-effects of an agent's tool calls. It doesn't run your agent or call an LLM: you keep your loop and model provider, and Pherix sits underneath at the tool-call layer. Zero dependencies — the kernel imports nothing; each adapter lazy-imports its own driver.
Python 3.12+. The kernel is dependency-free.
pip install pherix
Each adapter pulls only its own driver when you need it — e.g. pip install pherix[postgres]. From source: git clone https://github.com/LukeyP02/Pherix && cd Pherix && pip install -e .
Thirty seconds, no API key, no network — one reversible DB write that rolls back, one irreversible send that gates at commit:
python examples/quickstart.py
=== rollback === inside txn: ['ada'] after rollback: [] # the write was undone — nothing persisted === gate (not approved) === commit blocked: ... staged irreversible effects need approve_irreversible() emails sent: [] # the un-undoable send never fired users persisted: [] # and the DB write rolled back with it === gate (approved) === emails sent: [('grace@example.com', 'welcome')] users persisted: ['grace'] # approved → both go through
That's the whole idea in one run: the reversible effect is undone exactly, the irreversible one is held until a human says yes. The rest of this page walks through each piece.
Every side-effecting tool call becomes an Effect appended to one append-only, ordered journal held by a Transaction. A per-resource adapter makes each entry executable and reversible — snapshot → apply → restore. Every capability is then just a traversal of that one log:
apply each effect.restore the snapshot (or run a compensator).Two lanes come off the same engine:
DB and file writes run live and undo via the backend's own semantics — a real SQL SAVEPOINT / ROLLBACK TO SAVEPOINT, filesystem copy-on-write. Exact, not best-effort.
A charge or an email can't be snapshotted, so it stages — it only fires at commit, and with no semantic inverse it gates: commit blocks until a human approves.
Mark each side-effecting function with @tool and the resource it touches. The agent body that calls them stays transaction-unaware — just a plain loop.
import sqlite3 from pherix import AuditJournal, SQLiteAdapter, agent_txn, tool @tool(resource="sql") def insert_user(conn, name, role): conn.execute("INSERT INTO users (name, role) VALUES (?, ?)", (name, role)) return name def my_agent(team): # a plain agent loop — never transaction-aware for name, role in team: insert_user(name=name, role=role)
The connection is injected as the first argument by the adapter; the agent never sees it. resource="sql" routes the effect to the SQLite adapter you pass in next.
Wrap the run in agent_txn(...) with your adapters. Reversible effects journal live and roll back on demand; leaving the block cleanly commits them.
conn = sqlite3.connect("app.db", isolation_level=None) audit = AuditJournal.in_memory() adapters = {"sql": SQLiteAdapter(conn)} with agent_txn(adapters, audit=audit) as txn: my_agent([("ada", "engineer"), ("grace", "scientist")]) # caught a problem? roll the whole step back — nothing persisted: # txn.rollback() # left the block cleanly → commit. The writes are now durable.
Declare reversible=False. Add a compensator (a semantic inverse) if one exists; otherwise the effect blocks at commit until explicitly approved.
from pherix import HTTPAdapter, GateBlocked @tool(resource="http", reversible=False, injects_handle=False) def refund_charge(customer, amount): # the semantic inverse stripe.refund(customer, amount) @tool(resource="http", reversible=False, injects_handle=False, compensator="refund_charge") def charge_card(customer, amount): # auto-commits; refunded on rollback return stripe.charge(customer, amount) @tool(resource="http", reversible=False, injects_handle=False) def send_email(to, body): # no inverse — an email can't be un-sent mailer.send(to, body) with agent_txn(adapters, audit=audit) as txn: charge_card(customer="alice", amount=4200) receipt = send_email(to="alice@example.com", body="receipt") # send_email has no compensator → commit BLOCKS at the gate until a # human (or a higher-trust policy) approves the un-undoable effect: txn.approve_irreversible(receipt.effect_id) # no approval → GateBlocked is raised and the staged effects never fire.
supports_rollback() flag, not a guess. An HTTP POST adapter returns False, so the runtime forces that effect down the staged/gated path instead of pretending it can reverse it.An adapter teaches Pherix how to snapshot, apply, and restore one class of resource. Sixteen ship in Python, fourteen mirrored in TypeScript:
Each lazy-imports its driver, so import pherix needs none of them. Pull a driver only to instantiate the matching adapter: pip install pherix[postgres], pherix[s3], pherix[redis], … or pherix[all-adapters] for the lot.
You don't need a disaster to get value on day one. The append-only journal is a flight recorder for what your agent actually did — what ran, what was held, what was undone, and why. Point the read-only console at a run:
python -m pherix.inspector.seed demo.db # a representative journal python -m pherix.inspector --db demo.db # the read-only audit console
For the complete, executable integration recipe — written so a coding assistant can wrap your agent correctly — see llms-full.txt. Source and issues: github.com/LukeyP02/Pherix.