Architecture¶
The shape¶
flowchart LR
subgraph agent["your process"]
Q[["entry point<br/>(one queue)"]] --> R{route}
R -->|no interrupt_id| I["agent.invoke(input, thread)"]
R -->|interrupt_id| RS["agent.invoke(resume_command, thread)"]
I --> G[["graph.invoke()"]]
RS --> G
G -.->|ask() parks a node| CP[(checkpointer)]
G --> D{"diff pending()<br/>before vs after"}
end
D -->|new| CREATED["wait.created"]
D -->|gone| RESUMED["wait.resumed"]
CREATED & RESUMED --> A[["announce adapters"]]
A --> W(["the world:<br/>topic, queue, bus, table"])
W --> H([a human, or a timeout sweep])
H -->|"copy reply_with,<br/>set answer, post to reply_to"| Q
Two arrows are worth staring at.
The loop closes on the same queue it started from. An answer is not a special kind of
traffic arriving at a special endpoint; it is a message on the agent's ordinary entry
point, and interrupt_id is what distinguishes it. There is no answer API to build,
secure, scale or pay for.
The checkpointer is the only state. agent-wait writes nothing. What a thread is parked on is read from the framework every time it is asked, which is why two processes cannot disagree about it and why there is no repair pass.
The publisher¶
WaitPublisher.invoke() is the whole library:
before = {p.interrupt_id: p for p in adapter.pending(thread_id)}
result = adapter.invoke(value, config)
after = {p.interrupt_id: p for p in adapter.pending(thread_id)}
for new in after - before:
publish("created", ...)
for gone in before - after:
publish("resumed", ...)
Two get_state() reads per invoke. The framework stays the only source of truth, so
nothing can drift out of sync with it — there is nothing to be out of sync with.
That also determines what can be published at all. A wait has exactly two observable
states, parked and not, so there are exactly two transitions. v0.1's answered, expired
and cancelled were transitions of a record, describing the library's opinion about an
answer it had accepted; with no record and no answer, they have nothing to describe.
The adapter¶
Three methods, and one implementation:
class FrameworkAdapter(Protocol):
def config_for(self, thread_id: str) -> dict[str, Any]: ...
def invoke(self, value: Any, config: Any) -> Any: ...
def pending(self, thread_id: str) -> list[PendingInterrupt]: ...
The core never imports LangGraph. pending() is where all the framework's sharp edges get
handled, and there is one that matters.
LangGraph 1.2.x caveat: tasks[*].interrupts over-reports¶
Verified against langgraph 1.2.11 (langgraph #4796 / #6792). With two interrupts in one superstep, resume one, and the finished task still lists its interrupt id:
resumed = <id-F> (node 'na'); still parked = <id-G> (node 'nb')
task 'na' interrupts=['<id-F>'] result={'a': {'ok': 1}} error=None
task 'nb' interrupts=['<id-G>'] result=None error=None
get_state().next = ('nb',)
next says ('nb',) and task 'na' has a result — but na still advertises its
interrupt. The discriminator is task.result, which holds a finished task's return
value and is None only while the task is genuinely parked. pending() skips any task
with a result.
Getting this wrong is not subtle in its consequences. pending() decides which questions
are published as open, which answers are accepted as still live, and what republish()
re-announces. Reading tasks[*].interrupts naively would leave an answered approval
showing as open forever, and would accept a second approval for a node that already ran.
test_a_resumed_parallel_sibling_is_not_reported_as_pending asserts both the correct
behaviour and the raw over-report, so a LangGraph release that fixes the bug fails the
test rather than passing silently. The message on that assertion says the filter may then
be removable.
LangGraph 1.2.x limitation: two interrupting tools in one ToolNode¶
A second finding, recorded by the same spike, that agent-wait does not work around:
first invoke raised = [('<id-L>', {'which': 'a', 'x': '1'})]
after resuming it = [('<id-L>', {'which': 'b', 'x': '2'})]
same id for both? = True
tasks while parked on b = [('tools', ['<id-L>'], {})]
Two tools that both call interrupt(), dispatched by one ToolNode: only one surfaces
per invoke (#6624), and the
second carries the same id as the first
(#6626). That is a different
question under an identical dedupe_key — a consumer would discard it — and the task
shows result={} while genuinely parked, which pending() reads as finished.
There is no filter that fixes this, because the ids are genuinely equal. The rule is
one interrupt() per node: give each approval-requiring tool its own node.
test_known_limitation_two_interrupting_tools_in_one_toolnode_share_an_id asserts the
bug is present so that a LangGraph fix fails the test and this section gets removed.
What else was verified rather than assumed¶
packages/langgraph-wait/tests/test_spike_langgraph.py, which writes
reports/langgraph-spike-observations.txt on every run, pass or fail:
Interrupt.idis stable acrossinvoke(None, config)re-entry and across resume-from-checkpoint. Everything about republishing rests on this: if ids churned, every republish would look to a consumer like a new question.- Subgraph interrupts surface on the parent's state against the subgraph node's task,
with a stable id.
subgraphs=Trueis not needed, and resuming by id works through the parent.
Identity and deduplication¶
| value | stable? | |
|---|---|---|
interrupt_id |
LangGraph's | for as long as the question stands |
event_id |
a fresh ULID | no — one per publish |
dedupe_key |
"{type}:{interrupt_id}" |
yes |
Consumers deduplicate on dedupe_key. This is the mechanism that makes republishing safe,
and republishing is the only recovery path there is, so it carries weight:
- a crash between the invoke and the announce → the redelivered start message finds the
thread parked,
republish()re-announces it with the same key; - an announce adapter that was down → same;
- an adapter added after the question was asked →
republish()backfills it.
event_id exists for logs and for tracing one specific publish. Deduplicating on it would
defeat the whole scheme, which is why SqsAnnounce sends dedupe_key as the FIFO
MessageDeduplicationId and why the README says so twice.
expires_at is anchored to the checkpoint¶
PendingInterrupt.asked_at comes from LangGraph's created_at on the state snapshot, and
expires_at is asked_at + timeout.
It would be simpler to compute now + timeout at publish time, and it would be wrong. The
same question is republished on every redelivery, so a deadline measured from the publish
walks forward each time — and a thread retried often enough would never expire, which is
exactly the failure a timeout exists to prevent.
test_the_deadline_does_not_walk_forward_on_republish holds the line.
The two guards the host has to write¶
Neither is in the library, both are in examples/refund_agent/handler.py, and both are
one branch:
A start for a parked thread must republish, not re-invoke. Re-invoking with the
original input makes LangGraph run a fresh turn and ask the question again under a new
interrupt id — a duplicate that no dedupe_key can catch, because the ids genuinely
differ. v0.1 got this from a store of applied message ids; v0.2 gets it from
if agent.pending(thread_id).
An answer for a closed question must be dropped. pending() answers this against the
graph's own state, so a double click, a redelivered message and a second approver an hour
later are all rejected. It narrows the window rather than closing it: two answers in the
same instant both see the question open. SQS FIFO with MessageGroupId = thread_id
delivers one message per thread at a time, which closes the rest. Without ordering, a
conditional write of your own goes here.
docs/migrating-from-0.1.md has the full list of what moved across that line.
Failure isolation¶
CompositeAnnounce catches everything an adapter raises and logs it. The contract says
adapters must not raise; the composite enforces it rather than trusting it, because a
Slack outage must not fail a refund that has already been decided.
There is no retry and no record of what got through. Recovery is republish(), which is
idempotent by construction — so a retry loop would only be a faster way to do the same
thing.
If the graph itself raises, nothing is published. Nothing is known: a run that raised has not necessarily parked or unparked anything. There is no half-written record to repair, which is the advantage of keeping none.