Skip to main content

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.

Metadata

Release files for safeact 0.0.2

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for safeact 0.0.2
File Size Uploaded
safeact-0.0.2.tar.gz 27.8 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for safeact 0.0.2
File Interpreter ABI Platform
safeact-0.0.2-py3-none-any.whl Python 3 none any Details

Total release size: 49.4 kB

Release files / safeact-0.0.2.tar.gz

Download URL safeact-0.0.2.tar.gz
Size 27.8 kB
Tags Source
SHA-256 checksum
How to use checksums
1e6984bdab062dcda7ccd56b239d5e7a2935e222600fef5803b80913f71e666e
BLAKE2b-256 checksum
How to use checksums
986518e4a0c4b133715020a2da8995fc66d019342123dea5df6d85a1c5a25b6e
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.11.15

Release files / safeact-0.0.2-py3-none-any.whl

Download URL safeact-0.0.2-py3-none-any.whl
Size 21.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
53719b2992d4726875b1439bdbf9a9859b6d4cbcbb34e03bd67daaa94b326f46
BLAKE2b-256 checksum
How to use checksums
0dcf9ac46e32dba8a79f01a73fa93eaeab783f76b12b31a5bd2937ef76c32ddd
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.11.15

Release history Release notifications | RSS feed

This release

0.0.2 This release

2 release files

0.0.1

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page