At-most-once side effects for AI agents that crash and retry. One decorator, one Saga, a SQLite file.
Project description
safeact — SafeAct Core
An agent sent the same email 40 times because it crashed and retried. Here's the 10-line fix.
When an AI agent takes a real action — send an email, charge a card, write a row — and the process dies mid-run, the orchestration retries it. The action runs again. Developers report agents making dozens of duplicate API calls after a single crash-and-retry. Durable-execution engines (Temporal, DBOS) persist an agent's state; they don't make its actions safe. That gap is what this closes.
safeact is one decorator, one context manager, a SQLite file. No server.
The fix
Before — a crash after email.send() re-sends on retry:
def send_receipt(to, amount):
email.send(to=to, subject="Your refund", body=f"Refunded ${amount}.")
return {"sent": True}
After — at most once, forever, across restarts:
from safeact import safe
@safe(store="agent.db")
def send_receipt(key, to, amount): # key is the required first argument
email.send(to=to, subject="Your refund", body=f"Refunded ${amount}.")
return {"sent": True}
send_receipt("refund-8842", "a@b.com", 50) # runs once
send_receipt("refund-8842", "a@b.com", 50) # returns the stored result, no re-send
The key names the logical action. Same key seen again → the stored result is
returned instead of re-running. This holds across process restarts, because the
record lives in SQLite, not memory.
Why not just Temporal / DBOS?
They persist and replay your agent's state — where it was in the workflow.
Priceless for resuming. But on replay they happily re-run the code that talks to the
outside world. safeact guards the action itself — it's the last inch before
email.send() or stripe.charge(), a layer you drop underneath any orchestrator.
The four guarantees (each has a crash-test that fails if you remove it)
| Guarantee | What it means | Test |
|---|---|---|
| Idempotency | N retries of one key → exactly one execution, across restarts | tests/test_idempotency.py |
| Honest in-doubt | Crash in the gap → key is IN_DOUBT, next call raises InDoubtError — never a blind retry |
tests/test_in_doubt.py |
| Concurrency | Same key from many threads → one execution (DB UNIQUE, not a lock) |
tests/test_concurrency.py |
| Compensation | A failing Saga undoes completed steps in reverse, once each — even across a crash mid-rollback |
tests/test_saga.py |
These aren't claims — they're mutation-tested. Break the ordering contract in the store and 9 tests go red; strip the in-doubt refusal and 4 go red.
The record-before-execute contract (the whole point)
For every call with key X:
1. COMMIT (X, ATTEMPTING) <-- durable "I am about to do it" marker
2. run the real side effect
3. COMMIT (X, SUCCEEDED, result)
The committed ATTEMPTING marker in step 1 is what converts a silent duplicate
into a detectable event. Execute-first-record-second is the exact bug we eliminate.
The honest limitation: IN_DOUBT
If the process dies after the side effect ran but before step 3 commits, we
genuinely cannot know whether it happened. We do not guess. The key is marked
IN_DOUBT, and the next call with it raises InDoubtError rather than blindly
retrying. The error is a receipt, not a tombstone — it carries the key, tool
name, timestamp, and args-hash so you (or a reconciliation pass) can go ask the
downstream system what happened, then close it out:
from safeact import InDoubtError, get_store
try:
charge_card("charge-9", ...)
except InDoubtError as e:
print(e.key, e.tool, e.attempted_at, e.args_hash) # go check Stripe
store = get_store("agent.db")
for rec in store.list_in_doubt():
... # a human decides
store.resolve("charge-9", happened=True, result={...}) # or happened=False
An honest "we don't know, here's how to check" beats a confident duplicate.
Known false-positive (documented, not solved)
A live concurrent loser and a crashed predecessor both look like ATTEMPTING
from a single snapshot, so a concurrent loser can also raise InDoubtError. A
lease/heartbeat would separate the two — it's an explicit extension point, not
built, because building it now is how the scope balloons. Knowing where the edge is
beats pretending it isn't there.
Saga: compensation with automatic rollback
from safeact import Saga
with Saga("refund-8842", store="agent.db") as s:
s.step(charge_refund, 5000, compensate=reverse_charge)
s.step(send_receipt, "a@b.com", compensate=send_correction)
s.step(write_ledger, entry) # if this raises...
# ...send_receipt and charge_refund are compensated, in reverse order, once each.
Step keys are deterministic ({saga_id}:{index}:{name}), so re-running a crashed
saga re-attaches to prior progress instead of redoing it, and rollback is idempotent
— run it twice, nothing double-undoes. See examples/refund_agent.py.
The entire public API
from safeact import safe, Saga, InDoubtError, SafeError
# also: FailedCallError, SagaFailed, Store/get_store (for reading the ledger)
@safe(store, compensate=None)— make one tool idempotent + durable.Saga(saga_id, store)— context manager;.step(fn, *args, compensate=fn, **kwargs).InDoubtError— the honest gap state (carries the receipt).SafeError— base of everything.
Design note: a FAILED key refuses to re-run by default (FailedCallError) —
the library can't know why it failed, and re-running either hammers a doomed action
or risks duplicating a partial success. Use a new key to genuinely retry.
Run it
python3.11 -m venv .venv && ./.venv/bin/pip install -e ".[test]"
./.venv/bin/pytest -v # the crash-simulation suite
./.venv/bin/python examples/refund_agent.py
Python 3.11+, standard library only (SQLite is stdlib). pytest for the tests.
See DESIGN.md for the full state machine and rationale.
Deliberately not in this pass
Human-approval gates, a signed receipt format, multi-framework adapters, a web UI, auth, any network service. Each has a marked extension point in the code. The value here is doing four things provably, not ten things plausibly.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file safeact-0.0.1.tar.gz.
File metadata
- Download URL: safeact-0.0.1.tar.gz
- Upload date:
- Size: 27.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.11.15
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
fcf7aa05efcfa9dfbf029b57c047a68af4a3258819d5861fae8bdc06a5797efa
|
|
| MD5 |
309a1c167ca24d00cfca138c5f88ae35
|
|
| BLAKE2b-256 |
5406fafa9c8185b62b8b0bcdfaff6931f2e5028575e9f2ae931cf94cde3be407
|
File details
Details for the file safeact-0.0.1-py3-none-any.whl.
File metadata
- Download URL: safeact-0.0.1-py3-none-any.whl
- Upload date:
- Size: 21.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.11.15
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9ca81a37c5049c0d1f666d7f9a776f1aa6a18686aaa1eb8a848790b293bdaa06
|
|
| MD5 |
5cf9d7eacd9eec03052a360abf4171ec
|
|
| BLAKE2b-256 |
47add1926786d7ff21db96569930784bba99d29816eda3516167062762aba00f
|