interrupt(): human-in-the-loop interruption
Responsibilities
interrupt(value) is LangGraph's human-in-the-loop primitive, defined in libs/langgraph/langgraph/types.py (interrupt:811). Calling it once inside a node function raises a GraphInterrupt exception, wraps value as Interrupt(value=..., id=...) (Interrupt class:535) and delivers it to the client. The entire graph execution is paused at the current superstep boundary.
It sits between the node function and the Pregel loop: interrupt() itself does exactly one thing — raise the exception. The actual "pausing" logic lives in PregelLoop.tick: it detects the exception, writes Interrupt into pending writes, makes loop.tick() return False, and the main loop exits (raise GraphInterrupt:724). Once the client has the Interrupt, it prepares a resume value and re-invokes stream / invoke with Command(resume=...). The graph restores from the checkpoint, re-enters the interrupted node, and this time interrupt() returns the resume value provided by the client, then continues execution.
Design motivation
- Node-level pause, not graph-level: putting the interrupt point inside the node function — rather than using node-boundary config like
interrupt_before/interrupt_after— means the interrupt condition can be a runtime-computed result, e.g.if state["needs_review"]: answer = interrupt({"doc": ...}). No need to declare it statically when compiling the graph. - Multiple interrupts in the same node: a node can call
interrupt()several times in a row; LangGraph matches resume values in order of appearance.interrupt_counter()(interrupt_counter:15) increments on each call and pulls the corresponding value from thescratchpad.resumelist by idx. The resume list is task-scoped and not shared across nodes. - Node re-entry, not continuation: when
interrupt()raises, the node function is aborted and this execution's side effects (writes aside) are discarded. On resume, execution does not pick up from the line afterinterrupt()— it starts over from the top of the node. Thescratchpad.resumelist makes the priorinterrupt()calls return their values directly, skipping the branches that would have triggered interrupts, and naturally flowing to the nextinterrupt()or the end of the function. - Depends on a checkpointer:
interrupt()requires acheckpointer, because pausing needs to persist the channel snapshot and pending writes so resume can restore them.Command(resume=...)without a checkpointer raisesRuntimeErrordirectly (no checkpointer error:907). - Multiple pending interrupts require an explicit id: when several nodes are pending an interrupt at the same time in the same graph, providing a single resume value raises
RuntimeError, forcing you to specify an interrupt id (multi interrupt error:917).
Key files
interrupt:811— theinterrupt(value)function; uses the scratchpad internally to track consumed resume values.Interrupt class:535— theInterrupt(value, id)dataclass;from_nsusesxxh3_128_hexdigestto hash the namespace into an id.interrupt body:911— body ofinterrupt: pulls resume from the scratchpad, returns it if present, otherwise raisesGraphInterrupt.PregelScratchpad:9— holds task-scoped mutable state such asinterrupt_counter/get_null_resume/ theresumelist.raise GraphInterrupt:724— when preparing tasks,tickdetects a pending interrupt with no resume and raisesGraphInterruptto exit the main loop._pending_interrupts:819— computes the set of pending interrupts without a matching resume, used to decide whether an explicit multi-interrupt resume is needed.Command resume mapping:904— entry point that turnsCommand(resume=...)into pending writes; routes by interrupt id when resume is a dict.no checkpointer error:907— raises whenCommand(resume=...)is used without a checkpointer.multi interrupt error:917— raises when there are multiple pending interrupts but resume does not specify an id.should_interrupt:155— predicate for theinterrupt_before/interrupt_afterconfig; checks whether channel versions have changed.
Data flow
The core logic of interrupt() (interrupt body:911):
conf = get_config()["configurable"]
# track interrupt index
scratchpad = conf[CONFIG_KEY_SCRATCHPAD]
idx = scratchpad.interrupt_counter()
# find previous resume values
if scratchpad.resume:
if idx < len(scratchpad.resume):
conf[CONFIG_KEY_SEND]([(RESUME, scratchpad.resume)])
return scratchpad.resume[idx]
# find current resume value
v = scratchpad.get_null_resume(True)
if v is not None:
assert len(scratchpad.resume) == idx, (scratchpad.resume, idx)
scratchpad.resume.append(v)
conf[CONFIG_KEY_SEND]([(RESUME, scratchpad.resume)])
return v
# no resume value found
raise GraphInterrupt(
(
Interrupt.from_ns(
value=value,
ns=conf[CONFIG_KEY_CHECKPOINT_NS],
),
)
)The logic has three branches: (1) scratchpad.resume already has values (meaning this node has been re-entered and prior interrupt() calls have already consumed part of resume) — return the i-th value by idx, and write (RESUME, scratchpad.resume) once to persist the entire resume list into pending writes so a subsequent interrupt can still resume; (2) no resume has been consumed yet, but a resume value was supplied externally this call — get_null_resume(True) retrieves it, appends it to scratchpad.resume, and returns it; (3) neither — raise GraphInterrupt and throw the Interrupt to the runner.
Once GraphInterrupt is raised by the node, PregelRunner.tick treats it as a subclass of GraphBubbleUp — not as a node exception, but writes the Interrupt as an (INTERRUPT, interrupts) write into task.writes (error writes:596). When loop.after_tick finishes, apply_writes sees the INTERRUPT write and does not merge it into channels; it marks this step as paused due to an interrupt. On the next tick, _first detects an unresumed INTERRUPT in pending_writes (_pending_interrupts:819) and directly raise GraphInterrupt() to exit the main loop (raise GraphInterrupt:724).
Boundaries and failure modes
- A checkpointer is required: without one,
interrupt()cannot persist pending writes after raising, and resume cannot restore state.Command(resume=...)raises directly (no checkpointer error:907). - Side effects of node re-entry: any IO the node function performed before
interrupt()raised (writing files, calling external APIs) is not rolled back — on resume the entire node function runs from the top, so those side effects happen twice. When designing a node, either putinterrupt()at the very front, or move side effects afterinterrupt(). - Multiple pending interrupts require an explicit id: when a thread has multiple interrupts pending at once,
Command(resume="single_value")raises (multi interrupt error:917); you must pass a dict asCommand(resume={interrupt_id: value, ...})._pending_interrupts()is used to detect this case. - Time-travel mode discards cached resume: when replaying a historical checkpoint,
_firstdetectsis_time_travelingand filters outRESUME-typed pending writes (drop resume on replay:899), lettinginterrupt()fire again instead of returning the previous resume value — that is the only way to genuinely return to the state at the interrupt moment. - Scratchpad scope: the resume list for multiple
interrupt()calls within the same task is task-scoped and not shared across tasks. A subgraph'sinterrupt()uses its own scratchpad (distinguished byCONFIG_KEY_CHECKPOINT_NS); when the parent graph resumes a subgraph interrupt, it also routes by id. from_nshashes the id:Interrupt.idis computed by default withxxh3_128_hexdigest(ns)(from_ns:578) so that the interrupt id is stable for the same node in the same namespace, letting resume match it; passing an explicitidargument overrides the default.
Summary
interrupt() is the node-level human-in-the-loop primitive. It uses the scratchpad.resume list to "return resume values in order on node re-entry", and pauses execution via the GraphInterrupt exception plus checkpoint persistence. The companion Command(resume=...) is covered in /interrupt/command; the Pregel main loop's interrupt detection is in /pregel/loop. See official docs: LangGraph docs · README.