DEV Community

Cover image for Debugging a Stuck AI Agent: Reading Graph State in LangGraph.js
Gabriel Anhaia
Gabriel Anhaia

Posted on

Debugging a Stuck AI Agent: Reading Graph State in LangGraph.js


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));
}
Enter fullscreen mode Exit fullscreen mode

snap.next is the single most useful field. It tells you which node the graph
believes comes next:

  • next is empty and the task is unfinished → the graph reached END. A routing function sent it there.
  • next names a node that never ran → execution stopped before it, usually an interrupt.
  • next is 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"
}
Enter fullscreen mode Exit fullscreen mode

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,
})
Enter fullscreen mode Exit fullscreen mode

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) };
}
Enter fullscreen mode Exit fullscreen mode

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) };
}
Enter fullscreen mode Exit fullscreen mode

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.

A node returning undefined, leaving the router to re-evaluate unchanged<br>
state.

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));
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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: () => [],
}),
Enter fullscreen mode Exit fullscreen mode

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,
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

Per-node updates streaming as the graph runs, showing which channels each<br>
node<br>
wrote.

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
Enter fullscreen mode Exit fullscreen mode

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.

AI That Plans — Stateful AI Agents with LangGraph.js

The full series is at
xgabriel.com/ai-in-typescript.

Top comments (0)