Skip to content
agentFast
The production layer

Durable execution

Checkpoint per iteration; resume from the exact step after a kill.

In short: if your agent is interrupted — a crash, a deploy, a server restart — it picks up exactly where it stopped. It doesn't lose the work, and it doesn't redo the parts it already finished. This is the property everything else here depends on.

An agent run is a long-lived, side-effecting process. It calls tools that charge cards, open pull requests and send email. Treating it as an in-memory function call means a deploy, an OOM kill, or a spot-instance reclaim loses the run — or worse, re-runs half of it.

The guarantee

A run is checkpointed after every iteration, not at phase boundaries. After a process death, resume rebuilds from the last checkpoint and continues from the exact step it was on.

Resume replays state, never effects. A tool that already ran does not run again — its result was persisted with the checkpoint and is stitched back into the transcript.

run trace
llmclaude-sonnet-4-51 tool call$0.0031
toolkb_searchok · 3 hits — result persisted
llmclaude-sonnet-4-51 tool call$0.0028
toolorder_lookupok · ord_1001 — result persisted
hitlapproval_requested◀ SIGKILL here. Everything above survives.
The same run, across a process death. Steps 1–4 completed before the kill; after restart, the loop picks up at step 5 with the earlier results already in the transcript.

What a checkpoint holds

class StateSnapshot(BaseModel):
    run_id: str
    context: RunContext              # counters, budget, usage, session
    messages: list[Message]          # authoritative for loop-owning adapters
    sdk_state: dict[str, Any]        # opaque — the runtime never interprets it
    pending: PendingToolCall | None  # the call a paused run is waiting on
    saved_at: int

sdk_state is the interesting field. Graph-native SDKs like LangGraph persist their own channel state through their own checkpointer contract; agentFast stores and returns that blob without ever looking inside it. The house rule is that L3 never touches an SDK's persistence primitives directly — which is what stops the production layer from acquiring five special cases.

The subtle part: sibling tool calls

When a model emits several tool calls in one batch and one of them needs approval, the non-gated siblings execute first, and their results are persisted in snapshot.pending.executed_sibling_results.

Without that, resuming would either re-execute the siblings (double side effects) or lose their results (the model reasons from an incomplete transcript). Both are wrong in ways that are hard to notice until they cost money.

Budgets

Durability isn't a licence to run forever. Every run carries a budget checked at the top of each iteration:

budget:
  max_iterations: 50
  max_tokens: 150000
  max_duration_ms: 300000

Exceeding one ends the run as error with a human-readable reason rather than silently looping. The complexity classifier adjusts these per-run before the first model call, so a simple question doesn't carry a research budget.

The proof

The flagship test kills a real OS process mid-approval, against real Postgres, and asserts the run resumes from the exact step with exactly one refund issued:

pytest tests/test_flagship_kill_resume.py -q   # LangGraph
pytest tests/test_flagship_vanilla.py -q       # the vanilla loop
TipTry it yourself

The quickstart walks through doing this by hand with docker compose kill api. It takes about a minute, and it's more convincing than reading about it.