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.

  1. The model asks for refund_order("1042", 59.0).
  2. The tool runs and the refund goes through.
  3. The process is killed (an out-of-memory kill, a deploy, a node failure) before the node finishes.
  4. The run resumes. The run loop saved the current node before it started, so it runs that node again from the top.
  5. Without protection, refund_order runs 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:

  1. 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.
  2. 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 like call_1 reuse on every turn. Together, the key stays unique and stable across restarts.
  3. The ledger is checked before invoking. If the checkpointer holds a recorded result for that key, 10xGraph returns it immediately without calling the tool.
  4. 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 invoke and ainvoke tool path. The streaming tool path (stream and astream) does not consult or write the ledger, so streamed runs get no replay protection.
  • Missing ids. Without a tool_call_id or 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:

Terminal
pip install "10xgraph[pg_checkpoint]"
Python
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.

Replay safety works alongside two other mechanisms that handle related failure modes.

  • Versioned state writes. PgCheckpointer keeps 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) and tool_timeout (default 300 seconds) prevent hung calls from blocking workers forever. Configure per run: config={"thread_id": "t1", "tool_timeout": 60}. Pass None or 0 to disable. Defaults are in tenxgraph/utils/constants.py.

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.
Last updated for v0.10.0Edit this page on GitHubReport an issue