LangGraph
Resume a LangGraph agent after its process dies.
recall-langgraph
records the agent's working state, pointers to its work, and every turn to
Recall as it runs. The next process that opens the same agent id
starts from a briefing built from those records.
It sits beside LangGraph's checkpointer, not in place of it. The checkpointer still handles threads and interrupts however you configure it. The launch post explains the design and what we measured.
Install and use
pip install recall-langgraph langchain-openai
Durable tools need recall-langgraph 0.2.0 or later.
recall-langgraph also installs the polign_db server and polign CLI for
Linux, macOS and Windows, so there is no separate database to download.
langchain-openai is only for the model in this example.
from langchain_openai import ChatOpenAI from langgraph.prebuilt import create_react_agent from recall_langgraph import RecallResume, recall_tools with RecallResume("billing-migrator", local_dir="./recall-data") as resume: agent = create_react_agent( ChatOpenAI(model="gpt-4.1"), tools=[*your_tools, *recall_tools(resume)], prompt="Keep your working state current with update_working_state when your plan changes.", pre_model_hook=resume.pre_model_hook, post_model_hook=resume.post_model_hook, ) agent.invoke({"messages": [("user", "Carry on with the migration.")]})
Run it, kill it halfway, and run it again: the second run starts from what the first one wrote.
create_react_agent is deprecated in LangGraph 1.x in favor of
langchain.agents.create_agent, but it still ships in
langgraph.prebuilt and the hooks work with it.
What the hooks do
- Entering the
withblock takes a lease on the agent id, so only one process acts for the agent at a time, and reads back its records. The first time an id is used, it starts fresh. pre_model_hookputs the briefing in front of the messages before every model call: the working state, pointers, relevant memories and the most recent turns, within the token budget. It goes to the model only, so the graph's state never holds it.post_model_hookrecords the model's reply. Each message is recorded once, by its message id.- On the old thread id, a persistent checkpointer restores the old messages as well, and they are not recorded twice. The model then sees both the restored history and the briefing. Start a new thread if you want it to see only the briefing.
- Leaving the block hands the lease over, so the next process can resume at once. A process that dies without leaving it holds the lease until it expires.
The tools
recall_tools(resume) gives the model four tools.
| Tool | What the model uses it for |
|---|---|
update_working_state | Save its goal, plan, progress, focus, decisions, open questions and notes. Fields it leaves out are kept. |
milestone | Mark a durable point, such as tests passing. |
fetch_output | Read a large stored output in full, by the reference the briefing shows. |
set_pointer | Record where a piece of work lives: a git branch or commit, an object, an environment, an external item, or a process. |
Tool calls that touch the world
The records tell a new process what the agent was doing. They cannot tell it whether the
last tool call happened. If the process died while send_email was running, the
email may or may not have gone out, and an agent that carries on will often send it again.
Wrap those tools with resume.durable. Each call is then recorded before it runs
and closed with its result when it returns, and the calls a crash interrupted are settled
before the model sees its first briefing:
from recall_langgraph import ActionEnd, RecallResume, recall_tools def sent_folder(action): # Ask the outside system whether the call took effect. if mailbox.has_sent_since(action.started_at, subject_in=action.args): return ActionEnd("done", resolution="found in the sent folder") return None # cannot tell resume = RecallResume("support-agent", resolver=sent_folder) tools = resume.durable( [lookup_order, save_invoice, send_email], classes={"lookup_order": "pure", "save_invoice": "idempotent", "send_email": "external"}, ) agent = create_react_agent(model, [*tools, *recall_tools(resume)], pre_model_hook=resume.pre_model_hook, post_model_hook=resume.post_model_hook)
Every wrapped tool needs a class, given in classes or as the tool's
metadata["recall_class"]. A tool without one is refused when you call
durable, rather than given a guess.
| Class | Use it for | If a crash interrupted the call |
|---|---|---|
pure | Reads: lookups, searches, listings | Closed as abandoned. The model runs it again if it still needs the answer. |
idempotent | Writes that are safe to repeat with the same key: a PUT by id, a push of a known commit, a payment with an idempotency key | Run again with the same key before the first briefing, and the result goes into the history. |
external | Anything that could happen twice: send, post, charge, delete | Passed to resolver. Done or failed is recorded in the history. With no resolver, or when it returns None, the call is listed at the top of the briefing as one to check before repeating. |
An idempotent tool gets the key in an argument named idempotency_key, the same
key on every retry. Annotate it so the model is not asked for it:
from typing import Annotated from langchain_core.tools import InjectedToolArg, tool @tool def save_invoice(invoice: str, total: int, idempotency_key: Annotated[str, InjectedToolArg] = "") -> str: """Save an invoice.""" return billing.put(invoice, total, idempotency_key=idempotency_key)
Settling happens once per resume; call resume.settle() to do it at a point you
choose. A tool that raises is recorded as failed. A killed process or a cancelled call leaves
the action open, since nothing says whether it took effect. A LangGraph interrupt()
inside a tool closes the call as abandoned, because LangGraph runs the tool again when the
graph resumes. In a kill test of 540 runs, durable tools never repeated an email or an
invoice; Durable agent actions has the numbers.
Options
| Option | Default | What it does |
|---|---|---|
client | none | A polign_recall.Client opened with agent=True, to share one subprocess. Without it, RecallResume opens its own. |
local_dir | none | Keep the records in this directory on this machine and run a server for it. |
env | process environment | POLIGN_URL, POLIGN_API_KEY and the rest, for a server you run yourself. |
token_budget | 8000 | Upper bound on the briefing, in tokens. |
lease_ttl | 60 | Seconds each lease lasts. It is renewed in the background while the process runs. |
holder | host, pid and a random suffix | Names this process in the lease records. |
output_threshold | 2000 | Turns longer than this many tokens are stored whole as outputs and shown in the briefing as a reference. |
resolver | none | Called with each interrupted external call on resume, to check whether it happened. See Tool calls that touch the world. |
Where the records live
local_dir keeps the records on one machine. Agents that move between machines
need one shared server: run polign-server with its store in your S3, GCS or
Azure bucket where they can all reach it (Run polign_db shows
how) and pass its address through env or the environment:
RecallResume(
"billing-migrator",
env={"POLIGN_URL": "http://memory.internal:23000", "POLIGN_API_KEY": key},
)
The agent's records live in their own collection, named after the memory collection with
_agents added to the end.
Errors
lease_held: another process holds the agent, including one that died less than a lease time ago. Resuming raisespolign_recall.RecallErrorwith this code. Wait and try again, or stop.lease_lost: another process took the agent over mid-run. The next record fails with this code, which stops the graph run. This process should stop acting for the agent. On polign_db, PostgreSQL and MongoDB the server checks the lease on every write too, so a process that froze past its lease cannot land a late write.
Limits
- The briefing replaces exact state. Reasoning the agent never wrote down is not carried over.
Source & license
recall-langgraph is open source under the Apache License 2.0 and lives next to the
Python client in
github.com/Polign/polign,
along with its own StateGraph nodes and the benchmark harness. Bug reports and pull requests
go there.