Replay-safe tools
In shortReplay-safe tools let a resumed 10xGraph run skip tool calls that already finished, so a crash does not charge a card or send an email twice.
- 4 min read
- 7 sections
- Updated
- v0.10.0
- Markdown
Replay-safe tools prevent duplicate execution when a run resumes after a crash. Each finished tool call is recorded in the checkpointer as soon as it returns, so replayed runs use the recorded result instead of calling the tool again.
What failure does it prevent?
Agents call tools with side effects: refunds, emails, tickets, database writes. Consider a support agent that calls refund_order.
- The model asks for
refund_order("1042", 59.0). - The tool runs and the refund goes through.
- The process is killed (an out-of-memory kill, a deploy, a node failure) before the node finishes.
- The run resumes. The run loop saved the current node before it started, so it runs that node again from the top.
- Without protection,
refund_orderruns again and the customer is refunded twice.
Charges, emails, and tickets have the same risk pattern. The engineering blog post explores this in more detail.
How does it work?
The logic lives in tenxgraph/core/graph/utils/invoke_node_handler.py. Four steps ensure tools do not run twice:
- Node is persisted first. The run loop records the current node before it runs and advances only after completion. A killed process re-runs the interrupted node on resume.
- Each call gets a stable identity. The ledger key combines the assistant message id (persisted and replayed) with the
tool_call_id. Message id alone is stable across replays; tool call ids likecall_1reuse on every turn. Together, the key stays unique and stable across restarts. - The ledger is checked before invoking. If the checkpointer holds a recorded result for that key, 10xGraph returns it immediately without calling the tool.
- Result is recorded immediately. The record is written as soon as the tool returns, before the node completes. A crash later in the same node cannot re-fire it. Parallel tool calls in one node are tracked individually, so finished siblings are skipped on replay.
What does it not guarantee?
This provides at-most-once protection for recorded calls, not a global exactly-once guarantee. Gaps exist:
- A crash before the tool returns. If the process dies while the tool is running, nothing is recorded and the tool runs again on resume. The external side effect may have already happened.
- The gap after return. A short window exists between the tool returning and the record being written. A crash inside it can run the tool again.
- A failed record write. If the checkpointer cannot store the record, 10xGraph logs an error but continues. A replay may then execute the call a second time.
- A failed ledger read. If the ledger cannot be read, the tool runs again with a warning logged. 10xGraph treats an unreadable entry as “no record” because skipping a tool that never ran is worse.
- Timeouts. A tool exceeding its timeout is cancelled and raises an error with nothing recorded. If the provider already acted, the call may run again.
- Streaming runs. The ledger check lives in the
invokeandainvoketool path. The streaming tool path (streamandastream) does not consult or write the ledger, so streamed runs get no replay protection. - Missing ids. Without a
tool_call_idor message id, no ledger key can be built and the tool is not protected.
For these cases, always send an idempotency key to your payment or email provider. Derive it from stable inputs such as the order id, never from a random value generated inside the tool.
How do I enable it?
Replay safety requires two things: a checkpointer that implements the ledger, and a thread_id on every call. Without a checkpointer, tools behave as in any framework.
| Checkpointer | Ledger | Survives a process restart |
|---|---|---|
PgCheckpointer |
Yes, in a PostgreSQL tool_executions table |
Yes |
InMemoryCheckpointer |
Yes, in process memory | No |
SqliteCheckpointer |
Not implemented | Not applicable |
For production, install the extra and compile the graph with PgCheckpointer:
pip install "10xgraph[pg_checkpoint]"from tenxgraph.prebuilt.agent import ReactAgent
from tenxgraph.storage.checkpointer import PgCheckpointer
def lookup_order(order_id: str) -> dict:
"""Look up an order by id and return its status and total."""
return {"order_id": order_id, "status": "delivered", "total": 59.0}
def refund_order(order_id: str, amount: float) -> str:
"""Refund an order. Moves money, so it must not run twice for one request."""
# Also pass an idempotency key to your payment provider, derived from
# stable inputs, to cover the cases listed above.
return f"Refunded {amount:.2f} for order {order_id}"
checkpointer = PgCheckpointer(
postgres_dsn="postgresql://user:password@db/tenxgraph",
redis_url="redis://redis:6379/0",
)
app = ReactAgent(
model="google/gemini-2.5-flash",
provider="google",
system_prompt=[{"role": "system", "content": "You are a support agent for an online shop."}],
tools=[lookup_order, refund_order],
).compile(checkpointer=checkpointer)The API server uses the checkpointer carried by the compiled graph. For setup details, see Production checklist. After a crash, call the same thread with the same thread_id and the run resumes from the saved node.
Related protections: versioned writes and timeouts
Replay safety works alongside two other mechanisms that handle related failure modes.
- Versioned state writes.
PgCheckpointerkeeps a per-thread version counter and uses optimistic compare-and-swap for durability. This prevents concurrent runs on the same thread from overwriting each other’s state. Redis cache writes are guarded by the same version, so a stale run cannot regress the cache. The ledger protects side effects; versioned writes protect state. - Timeouts.
node_timeout(default 900 seconds) andtool_timeout(default 300 seconds) prevent hung calls from blocking workers forever. Configure per run:config={"thread_id": "t1", "tool_timeout": 60}. PassNoneor0to disable. Defaults are intenxgraph/utils/constants.py.
Related pages
Frequently asked questions
- Does 10xGraph guarantee a tool runs exactly once?
- No. It guarantees that a tool call recorded in the checkpointer's ledger is not executed again. A crash in the short window after a tool finishes but before its record is written can still run it again, and a tool that crashes or times out before returning is not recorded. Pass your own idempotency key to the external service for those cases.
- Which checkpointers support replay-safe tools?
- PgCheckpointer stores the ledger durably in PostgreSQL. InMemoryCheckpointer keeps it in process memory, so it only protects replays inside the same process. SqliteCheckpointer does not implement the ledger and falls back to at-least-once behavior.
- Do I need to change my tool code to make it replay-safe?
- No. The check happens around the tool call, not inside it. Write the tool as a normal function. Add an idempotency key to calls to payment or email providers as a second layer of protection.
- What happens when a replayed tool call is skipped?
- 10xGraph returns the result that was recorded when the tool first ran, so the model still sees a normal tool result and the run continues from where it was interrupted.