- Book: AI That Plans
- The series: AI in TypeScript — 5 books, from your first LLM call to agents in production — all five here
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
A stuck graph rarely hangs. It returns — quickly, with a state object that
looks almost right, having skipped the work entirely. There is no exception
and no log line saying what did not happen.
The state is on disk, though. Reading it correctly turns "the agent is broken"
into a specific answer in about five minutes.
Read the checkpoint first
Before theorising, get the state and the history.
const config = { configurable: { thread_id: threadId } };
const snap = await graph.getState(config);
console.log({
next: snap.next, // nodes queued to run
values: Object.keys(snap.values),
tasks: snap.tasks?.map((t) => ({ name: t.name, error: t.error })),
});
for await (const s of graph.getStateHistory(config)) {
console.log(s.metadata?.step, s.next, Object.keys(s.values));
}
snap.next is the single most useful field. It tells you which node the graph
believes comes next:
-
nextis empty and the task is unfinished → the graph reachedEND. A routing function sent it there. -
nextnames a node that never ran → execution stopped before it, usually an interrupt. -
nextis the node you are already on → a loop, or a node that returned nothing.
getStateHistory gives the sequence of checkpoints. Watching which channels
change per step shows exactly where progress stopped.
Cause 1: the router returned a string nothing matches
function route(state: State) {
if (state.needsTools) return "tools";
return "answer"; // typo'd elsewhere as "answers"
}
A conditional edge whose return value matches no node sends the graph to END
in some configurations rather than throwing. The run "completes" instantly.
The fix is a return type the compiler checks, and a map that makes the
destinations explicit:
type Next = "tools" | "answer" | typeof END;
function route(state: State): Next {
if (state.needsTools) return "tools";
if (state.turns > 10) return END;
return "answer";
}
.addConditionalEdges("agent", route, {
tools: "tools",
answer: "answer",
[END]: END,
})
Passing the mapping object as the third argument means an unmapped value fails
at wiring time rather than silently routing to nowhere. Annotating the return
type stops TypeScript widening it to string, which is what allows the typo
in the first place.
Cause 2: a node returned nothing
async function enrich(state: State) {
if (!state.orderId) return; // ← returns undefined
return { order: await fetchOrder(state.orderId) };
}
A node returning undefined writes no channels. State is unchanged, the
router runs on the same values as last time, and the graph either loops or
exits — both without an error.
Return an explicit empty update and record why:
async function enrich(state: State) {
if (!state.orderId) {
return { notes: ["enrich skipped: no orderId"] }; // reducer appends
}
return { order: await fetchOrder(state.orderId) };
}
A notes channel with an append reducer is worth adding for this alone. It
turns "nothing happened" into a readable trail inside the state you are
already persisting.
Cause 3: a pending interrupt nobody answered
const snap = await graph.getState(config);
if (snap.tasks?.some((t) => t.interrupts?.length)) {
console.log("waiting on:", snap.tasks.flatMap((t) => t.interrupts));
}
If the graph paused at an interrupt() and your approval UI never delivered a
decision, the run sits there indefinitely. From the outside it looks identical
to a hang.
The other half of this is the resume call itself, which has a subtlety worth
knowing:
// answers a pending interrupt
await graph.invoke(new Command({ resume: "approve" }), config);
// starts a NEW pass through the graph
await graph.invoke({ messages: [...] }, config);
Using Command({ update }) for a follow-up turn resumes from the latest
checkpoint — the last step that ran — rather than restarting from the entry
point, and the symptom is a run that returns immediately having done nothing.
That is the single most common "my graph is stuck" report, and it is a resume
called the wrong way.
Cause 4: a missing reducer ate the update
const State = Annotation.Root({
messages: Annotation<BaseMessage[]>(), // no reducer
});
Without a reducer a channel replaces on every write. Each node overwrites
the message history instead of appending, so the model sees only the last
node's output and behaves as if it has amnesia.
Nothing errors. The graph runs to completion and the answers are subtly wrong.
messages: Annotation<BaseMessage[]>({
reducer: messagesStateReducer,
default: () => [],
}),
The tell in the state history: messages length that does not grow, or
shrinks, between steps. Worth checking first whenever quality is bad but the
graph "works".
Make it visible while it runs
Post-mortem reading works. Watching is better:
for await (const chunk of await graph.stream(input, {
...config,
streamMode: "updates",
})) {
for (const [node, update] of Object.entries(chunk)) {
logger.info("node", {
node,
wrote: Object.keys(update ?? {}),
threadId,
});
}
}
streamMode: "updates" emits what each node wrote. A node appearing with
wrote: [] is cause 2, live. A node that never appears is cause 1 or 3.
Ship this behind a debug flag rather than adding it during an incident.
Reproduce from the checkpoint, not from the input
Once you have a thread id, you can replay the exact state rather than trying
to recreate it:
const history = [];
for await (const s of graph.getStateHistory(config)) history.push(s);
const before = history.find((s) => s.next.includes("answer"));
await graph.invoke(null, before.config); // resume from that checkpoint
Resuming from a specific checkpoint config re-runs from that point with the
state as it was. That is a genuine time-travel debugger, and it is the
strongest argument for a durable checkpointer beyond crash recovery.
The five-minute routine
Get the thread id. getState and read next. If it is empty and the task is
unfinished, look at the routing function. If it names an unrun node, check for
pending interrupts. If it is the current node, look for a node returning
undefined. Then scan getStateHistory for a channel that stopped growing,
that is your missing reducer.
Four causes cover nearly all of it, and every one is visible in state you are
already storing.
If this was useful
AI That Plans covers LangGraph.js
state in depth — annotations and reducers, typed routing, interrupts and
resume, and using checkpoints to debug a run rather than guess at it.
The full series is at
xgabriel.com/ai-in-typescript.



Top comments (0)