Skip to main content

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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

safeact-0.0.1.tar.gz (27.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

safeact-0.0.1-py3-none-any.whl (21.6 kB view details)

Uploaded Python 3

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

Hashes for safeact-0.0.1.tar.gz
Algorithm Hash digest
SHA256 fcf7aa05efcfa9dfbf029b57c047a68af4a3258819d5861fae8bdc06a5797efa
MD5 309a1c167ca24d00cfca138c5f88ae35
BLAKE2b-256 5406fafa9c8185b62b8b0bcdfaff6931f2e5028575e9f2ae931cf94cde3be407

See more details on using hashes here.

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

Hashes for safeact-0.0.1-py3-none-any.whl
Algorithm Hash digest
SHA256 9ca81a37c5049c0d1f666d7f9a776f1aa6a18686aaa1eb8a848790b293bdaa06
MD5 5cf9d7eacd9eec03052a360abf4171ec
BLAKE2b-256 47add1926786d7ff21db96569930784bba99d29816eda3516167062762aba00f

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page