Metadata-Version: 2.4
Name: idempotent-tools
Version: 0.1.0
Summary: Idempotency-key wrapper for agent tool calls (LangChain/LangGraph/CrewAI and plain Python).
License: MIT
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: redis
Requires-Dist: redis>=4.0; extra == "redis"
Provides-Extra: test
Requires-Dist: pytest>=7.0; extra == "test"
Dynamic: license-file

# idempotent-tools

Idempotency-key decorator for agent tool calls — local-only, zero-config, no
network call in the hot path.

Agent frameworks (LangGraph, CrewAI, LangChain) retry or resume tool
invocations after an interrupt, checkpoint replay, or failure. If the tool
has a side effect (charging a card, sending an email, placing a trade,
writing to an external API), that retry can fire it twice. There's no
packaged library that owns this problem today — teams are hand-rolling the
same "check a local store before running" decorator repeatedly (see e.g.
crewAI issue #5802 and multiple independent write-ups of the same DIY
pattern). `idempotent-tools` packages that pattern: a decorator that dedupes
calls against a small local store, so a retried/resumed call returns the
prior result instead of re-executing.

Some competing solutions in this space are cloud-hybrid products — a hosted
API for the dedup check, with a metered/paid production tier. This library
is not that: it's a single decorator backed by SQLite (or Redis if you
already run one), MIT-licensed, with no account, token, or outbound network
call required to use it. That also means it doesn't do everything a hosted
service can (see **Out of scope** below) — it trades distributed
cross-worker locking for simplicity and zero external dependency.

## Install

```bash
pip install -e .
# or, with Redis backend support:
pip install -e ".[redis]"
```

Requires Python >= 3.9. No required dependencies for the default (SQLite)
backend.

## Quick start

```python
from idempotent_tools import idempotent

@idempotent
def charge_card(order_id: str, amount: float) -> dict:
    ...  # side-effecting call

charge_card("order-42", 19.99)   # runs
charge_card("order-42", 19.99)   # returns the cached result, does not re-run
```

By default the idempotency key is a SHA-256 hash of the function name plus
its JSON-serialized `args`/`kwargs`, stored in a zero-config SQLite file at
`~/.idempotent_tools/store.db`. You can also pass an explicit key at call
time:

```python
charge_card("order-42", 19.99, idempotency_key="charge-order-42")
```

or derive one from framework context (thread id, task id, step, etc.) via
`context=` or `key_fn=` — see the integration examples below.

## Execution semantics

Every call, the decorator looks up its key and does one of:

- **Not seen** (or a stored record has expired past `ttl`) -> function runs,
  result is stored as `completed`.
- **Seen, status `completed`** -> the stored JSON result is returned; the
  function does not run.
- **Seen, status `pending`** (another call with the same key is currently
  in flight) -> behavior is controlled by `on_duplicate`:
  - `"raise"` (default) -> raises `DuplicateInFlightError` immediately.
  - `"block"` -> polls (`poll_interval`, default 0.2s) until the in-flight
    call resolves, then returns its result. If it fails or times out
    (`poll_timeout`, default 30s -> raises `TimeoutError`), falls through
    and re-runs.
  - `"retry"` -> runs the function again immediately, without waiting,
    overwriting the stored result when it finishes.
- **Seen, status `failed`** (the previous attempt raised) -> treated as
  not-seen; the function runs again.

`ttl` (seconds, default `None` = never expires) makes a record older than
the TTL treated as not-seen on the next call, regardless of its status.

Non-JSON-serializable arguments raise `TypeError` at call time when no
`idempotency_key=` or `key_fn=` is supplied (there's no fallback
serialization — see "Out of scope" below).

## Storage backends

Both backends implement the same interface (`get`, `acquire`, `complete`,
`fail`, `list_records`, `clear`) defined in
`idempotent_tools.backends.base.Backend`, so you can pass either as
`backend=` to `@idempotent`.

- **`SQLiteBackend`** (default): file-based, zero config, thread-safe
  (guarded by an internal lock; stress-tested under concurrent threads).
  Defaults to `~/.idempotent_tools/store.db`; pass `SQLiteBackend(db_path=...)`
  to use a different file.
- **`RedisBackend`** (optional, only imported if `redis` is installed):
  `pip install idempotent-tools[redis]`, then
  `RedisBackend(url="redis://localhost:6379/0")`. Note: its `acquire` is a
  read-then-write check, not a single atomic Redis operation — fine for
  single-process use, but it does not provide true cross-process/cross-worker
  locking (see "Out of scope").

## Framework integration

`idempotent_tools.integrations` has two thin helper modules for deriving a
`context=` value from framework state — they don't wrap the frameworks
themselves, you still apply `@idempotent` directly to your tool function.

### LangGraph

```python
from langchain_core.tools import tool
from idempotent_tools import idempotent
from idempotent_tools.integrations.langgraph_shim import thread_step_context

@tool
@idempotent(context=lambda: thread_step_context(current_config(), step=current_step()))
def send_payment(order_id: str, amount: float) -> dict:
    ...
```

`current_config()` / `current_step()` are stand-ins for however your graph
node exposes the current `RunnableConfig` and step number (closure
variables, or LangGraph's own config accessor). Put `@idempotent` *closer*
to the plain function than `@tool`, so it wraps the raw callable before
LangChain adapts the calling convention.

### CrewAI

```python
from crewai.tools import tool
from idempotent_tools import idempotent
from idempotent_tools.integrations.crewai_shim import task_context

current_task = None  # set by a hook/callback before each tool invocation

@tool("send_email")
@idempotent(context=lambda: task_context(current_task))
def send_email(to: str, subject: str) -> dict:
    ...

def before_task_attempt(task):
    global current_task
    current_task = task
```

Wire `before_task_attempt` into a CrewAI Task/Agent callback so
`current_task` reflects the task being (re)attempted. `task_context` keys
off the task's `id`/`key` only (not its retry count), so repeated attempts
of the same task map to the same idempotency key.

## CLI

```bash
idempotent-tools inspect            # list all stored records
idempotent-tools inspect --key K    # show one record
idempotent-tools clear              # clear all records
idempotent-tools clear --key K      # clear one record
idempotent-tools --db path/to.db inspect   # point at a specific SQLite file
```

The CLI only operates on the SQLite backend/file; it has no Redis support.

## Manual demo

```bash
python demo.py
```

Runs `charge_card` twice with the same key against a temp SQLite file and
asserts it only actually executed once.

## Tests

```bash
pip install -e ".[test]"
pytest
```

## Out of scope for v1

- **Distributed cross-worker locking/consensus.** `acquire()` on both
  backends is not a single atomic compare-and-swap across processes/hosts;
  don't rely on it to prevent two independent workers racing on the same
  key at the exact same instant beyond what SQLite's own locking or Redis's
  per-command atomicity gives you.
- **Non-JSON-serializable arguments/results.** Auto-keying and result
  storage both go through `json.dumps`; pass objects, and you'll get a
  `TypeError` telling you to supply `idempotency_key=` or `key_fn=` instead.
- **Dashboard/UI.** Inspection is CLI-only (`idempotent-tools inspect`).
