Cheat sheets

Agent State Machines and Durable Graphs

State, nodes, edges, reducers, checkpoints, interrupts and leased job recovery. LangGraph and a Go job queue.

  • Concepts and terms
  • Tasks and commands
  • Annotated code
  • Diagrams
  • Comparisons
  • Defaults and numbers
  • Troubleshooting
  • Questions and tips

Source

The parts of a state graph#

State
a typed dict that flows through the graph. Each node gets the full state.
Node
a function state -> partial_state. One discrete step.
Edge
static edges go to one node. Conditional edges call a router function.
Reducer
(old, new) -> merged per field. Default is overwrite.
Thread
a thread_id string that scopes all checkpoints of one session.
Checkpoint
the full state after a node, keyed on (thread_id, checkpoint_id).
Send
a node returns Send(node_name, state) to run nodes in parallel.
Subgraph
a compiled graph used as one node in another graph.

The four-node ReAct graph#

class State(TypedDict):
messages: Annotated[list[AnyMessage], add_messages]append, not overwrite
def should_continue(state: State) -> str:
last = state["messages"][-1]
return "tools" if getattr(last, "tool_calls", None) else END
graph = StateGraph(State)
graph.add_node("agent", agent_node)calls the LLM
graph.add_node("tools", ToolNode(TOOLS))runs tool_calls
graph.set_entry_point("agent")
graph.add_conditional_edges("agent", should_continue, {"tools": "tools", END: END})
graph.add_edge("tools", "agent")static edge back
app = graph.compile(checkpointer=MemorySaver(),
interrupt_before=["tools"])pause before side effects

ReAct loop with a checkpointer#

messagestool_callstool resultsdonesaveclientUserthread_idmodelagentcalls the LLMprocesstoolsToolNodeENDno tool_callsstoreCheckpointerstate after each nodemessagestool_callstool resultsdonesaveclientUserthread_idmodelagentcalls the LLMprocesstoolsToolNodeENDno tool_callsstoreCheckpointerstate after each node

Graph tasks#

TaskCode
Set the thread{"configurable": {"thread_id": "<id>"}}
Stream deltasapp.stream(<state>, config, stream_mode="updates")
Read paused stateapp.get_state(config)
Approve and resumeapp.invoke(Command(resume=True), config)
Deny with a messageapp.update_state(config, {"messages": [...]})
List checkpointslist(app.get_state_history(config))
Replay from a checkpointapp.stream(None, <old_config>)
Postgres storePostgresSaver.from_conn_string(<url>)

Stream modes#

Factorupdatesmessagesvalues
YieldsState deltasLLM tokensFull snapshots
Shape{node_name: state_delta}Tokens in model nodesWhole state
Use forThe UIToken streamingEval

Pause, approve, resume#

1Compileset interrupt_before=["tools"] and acheckpointer.2Runthe graph stops before tools. The statepersists.3APIanswers the user with "awaiting approval".4Reviewerreads the pending tool_calls fromapp.get_state(config).5Approvesend Command(resume=True) on the same thread_id.6Denywrite a rejection message with app.update_state,then resume.

Tips#

  • Name the nodes first. Each decision or side effect is a node.
  • Hoist task fields such as plan and budget out of messages.
  • Prefer linear chains with occasional branches.
  • Put approvals on the edge into a side-effecting node.
  • Do not put the job store on NFS.
  • Use a new store for each repeatable crash test.

Stdlib graph: pause and resume#

1classifysets route to refund, bug or sales.2Routeradd_conditional_edges picks the branch node fromroute.3Branchwrites a ticket such as BUG-....4human_gateno human_approval, so it sets _pause_reason.5Runnersaves the state, then raises PausedAtNode.6Humanload_latest(session), set human_approval toTrue.7Resumerunner.run(..., resume_from="send",state_override=...).

Durable job states#

ClaimJobFinishReclaimcapNewJobversion 0queuequeuedwaits for a claimprocessrunningLeaseUntil = now +ttlstorecompletedLeaseUntil 0ErrLimitAttempts at capClaimJobFinishReclaimcapNewJobversion 0queuequeuedwaits for a claimprocessrunningLeaseUntil = now + ttlstorecompletedLeaseUntil 0ErrLimitAttempts at cap

Run the job queue#

CommandWhat it does
go build -o /tmp/durable-jobs .Builds the reference worker
enqueue --store "$STORE" --input samples/jobs.jsonAdds id/text records
worker --store "$STORE" --max-jobs 100Claims and runs jobs
status --store "$STORE"Shows jobs, effects and scope
worker ... --crash-after effectExits 86 after the receipt
worker ... --now-ms 111 --lease-ms 10Recovers the expired claim
--crash-after claimCrash before any output exists
go run . demoAutomated effect crash sequence

Worker defaults#

SettingDefaultNote
--lease-ms30000Claim lease in milliseconds
--max-attempts3Job stays visible at the cap
--max-jobs1Jobs per worker call
--delay-ms0Range 0..60000
--now-ms-1Means current wall time
--storejobs-dataLocal directory
Snapshot size4 MiBErrLimit above it

Troubleshooting#

SymptomCauseFix
Half the turn lostNo add_messages reducerAnnotated[list, add_messages]
No resume or time-travelNo checkpointerMemorySaver in tests, a real store in prod
Harm before approvalInterrupt after the side effectInterrupt before the side effect node
Tool state lost on resumeCheckpoints too smallSerialize the full state
Resume gives a new resultNon-deterministic nodeCapture seeds, clock and API replies
Old worker completion failsVersion or lease changedExpected. Finish fences stale workers
Enqueue conflictChanged text under an old IDUse a new identifier
Exit code 86--crash-after setExpected. Run the worker again

Q&A#

What does None as input do?
It replays from the given checkpoint.
Why completed v4 after one crash?
Claim, reclaim, claim and finish each add 1.
Is execution exactly once?
No. It is at least once.
Why no duplicate receipt?
The local immutable receipt makes the effect idempotent.
When does Reclaim fire?
On a running job with now at or past LeaseUntil.
Which three topologies?
Supervisor, swarm and hierarchical.