Architecture¶
The shape¶
flowchart LR
subgraph host["your process"]
Q[["entry point"]] --> R{"question_id<br/>in message?"}
R -->|no| I["graph.invoke(input, cfg)"]
R -->|yes| RS["graph.invoke(Command(resume=...), cfg)"]
I --> G[["LangGraph"]]
RS --> G
G -.->|"a @wait call parks the node"| CP[(checkpointer)]
G --> P["publish_interrupts(result, thread_id, announce)"]
end
P -->|"one envelope per Interrupt"| A[["announcers"]]
A --> W(["topic · queue · webhook · table"])
W --> H([a human, or a system])
H -->|"reply_with + answer"| Q
The library is the P box. Everything else in the host is yours, and it is one if.
What publish_interrupts reads, and why it needs nothing else¶
graph.invoke() returns the run's output. If a node called interrupt(), the output
carries __interrupt__: a sequence of Interrupt objects. Per the LangGraph reference,
an Interrupt has two attributes — value (what was passed to interrupt()) and id.
ns, when and resumable were removed in 0.6.
That is the entire input. @wait packed the question and the policy into value;
id is what the answer has to carry back; thread_id is the one thing the result does
not echo, so the host passes it. No graph handle, no get_state(), no checkpointer —
the library never touches persistence, and it is not in the process when the answer
arrives.
for interrupt_id, value in framework.interrupts_in(result): # LangGraph: result["__interrupt__"]
question, policy, source = unpack(value)
envelopes.append(build_envelope(thread_id, Question(interrupt_id, question, policy, source=source)))
Interface and implementors¶
The user-facing pieces — @wait, publish_interrupts, the envelope, the announcers — are
written once in the core against a three-method interface, and each framework subpackage
is one implementor of it:
class Framework(ABC):
name: str
hidden_params: tuple[str, ...] # injected params to keep out of the published args
def interrupt(self, value, call_args) -> Any: ... # park on `value`; on resume, return the answer
def interrupts_in(self, result) -> list[tuple[str, Any]]: ... # (id, value) from what invoke() returned
def current_thread_id(self, call_args) -> str: ... # async mode only
agent_wait.langgraph is LangGraphFramework plus two lines of binding:
wait = make_wait(LangGraphFramework())
publish_interrupts = make_publish_interrupts(LangGraphFramework())
That is the whole subpackage. A user never sees the class; they import the bound names.
The same split holds for announcers: BaseAnnounce is the interface, agent_wait.aws
is implementors. Adding a framework or a provider is a subpackage and an extra in
pyproject.toml, and nothing in the core changes — agent_wait.pydantic_ai and
agent_wait.strands were each added without touching it. The list of what is supported, and what each framework leaves to the
host, is on Frameworks and providers.
LangGraph ([langgraph]) |
Pydantic AI ([pydantic-ai]) |
|
|---|---|---|
interrupt() |
langgraph.types.interrupt(value); on the re-run it returns the resume value |
raise ApprovalRequired(metadata=value); on the re-run (ctx.tool_call_approved) it returns ctx.tool_call_metadata, or {"action": "approve"} if none |
interrupts_in() |
result["__interrupt__"] → (Interrupt.id, .value) |
result.output when it is a DeferredToolRequests → (tool_call_id, metadata[id]) for every approval and deferred call |
current_thread_id() |
get_config()["configurable"]["thread_id"] |
ctx.deps.thread_id (or deps["thread_id"]) — the framework has no thread of its own |
hidden_params |
— | ctx (the RunContext) |
| persistence | the checkpointer | none: message_history is the host's, stored under thread_id |
| resume (host code) | Command(resume={id: answer}) |
DeferredToolResults(approvals={id: ToolApproved() / ToolDenied(...)}, metadata={id: answer}) |
| duplicate answer | ignored by the framework | refused only if the history passed already holds the tool's return; else the tool runs again |
The user's code is the same on both: @wait(policy) on the function,
publish_interrupts(result, thread_id, announce) after the run, and one if on
question_id when the answer comes back. Only the resume line is the framework's own.
On Pydantic AI, ToolApproved makes the framework call the tool again with the original
arguments and ctx.tool_call_approved = True; the @wait wrapper then applies the same
rules as on LangGraph — a decision parameter receives the answer, answer["args"]
edits the call — because the answer rides in DeferredToolResults.metadata. ToolDenied
makes the framework skip the tool and hand the model the denial message, which is the
"anything else is returned in its place" rule done by the framework itself. The host
passes the answer through metadata, not override_args: override_args replaces the
whole argument set and bypasses the wrapper's merge.
Two interrupt shapes, one envelope¶
| raised by | Interrupt.value |
published as |
|---|---|---|
a @wait call |
{"question": {"function", "args"}, "__wait__": policy, "__source__": …} |
one envelope, that policy |
bare interrupt(value) |
value |
one envelope, default policy |
LangChain's HumanInTheLoopMiddleware raises a third shape — one interrupt for a whole
batch of tool calls — and is not supported: @wait on a tool the middleware also
intercepts interrupts twice. It is one or the other, and this library is the other.
The two modes of @wait¶
Interrupt (default): the wrapper raises the interrupt with {"function", "args"}
before the body — unless when= is given and returns false for these arguments, in which
case no question is raised at all and the body simply runs. The predicate sees the
published args only, is re-evaluated on the resume re-run (so it must be deterministic),
and parks the call if it raises. The thread parks. publish_interrupts after the run announces it. If
the function declares a decision parameter it always runs and receives the answer;
otherwise {"action": "approve"} runs it, {"action": "approve", "args": {…}} runs it
with those, and anything else is returned in its place, unexecuted.
Async: the wrapper is the publisher. It announces through the decorator's own
announcers, with question_id = sha256(thread | function | args), and returns
{"status": "pending_approval", "question_id"} without running the body. Nothing is
parked; nothing is called after the run; the graph carries on. The decision arrives as a
new message and the graph acts on it however it was designed to.
The difference is where publishing happens — inside the function at call time, or after the run from the result — and that in async mode LangGraph remembers nothing. The envelope, the announcers and the recommended answer shape are identical.
Identity¶
| value | stable? | |
|---|---|---|
question_id |
Interrupt.id (interrupt mode) or sha256(thread\|function\|args) (async) |
for as long as the question stands |
event_id |
a fresh ULID | no — one per publish |
dedupe_key |
"wait.created:<question_id>" |
yes |
Consumers deduplicate on dedupe_key. The same question is published again whenever a
start message is redelivered (LangGraph hands back the same Interrupt.id on re-entry —
verified) or an async tool is called again with the same arguments. SqsAnnounce sends
the key as the FIFO deduplication id; DynamoDbAnnounce uses it as the item key;
WebhookAnnounce sends it as a header.
What LangGraph does with a duplicate answer¶
Nothing. A Command(resume=…) for a question the thread has already moved past re-runs
no node and returns the current state — verified on 1.2.11. So the host does not check
for duplicates, and the library does not offer a way to. The same holds for a different
answer arriving after the first: the first stands.
LangGraph 1.2.x limitation: two interrupting tools in one ToolNode¶
Recorded by the spike, not worked around:
first invoke raised = [('<id-L>', {'which': 'a', 'x': '1'})]
after resuming it = [('<id-L>', {'which': 'b', 'x': '2'})]
same id for both? = True
Only one surfaces per run (#6624), and the second carries the same id as the first
(#6626): a different question under an identical dedupe_key, which a consumer would
discard. Interrupt.id is a hash of the checkpoint namespace alone; the counter that
routes resume values back to the right interrupt() call is not part of it. The rule is
one interrupt() per node. The spike asserts the bug is present so a LangGraph fix
fails the test and this section gets removed.
Failure isolation¶
CompositeAnnounce catches everything an adapter raises and logs it; BaseAnnounce does
the same for its subclasses' deliver(). A Slack outage must not fail a run that has
already parked. There is no retry: the next redelivery republishes with the same
dedupe_key, and that is the retry.
What is not here, on purpose¶
No store, no tokens, no leases, no inbound validation, no timers, no get_state().
Each would put the library on the receive path, where every team's own opinions live. The receive path is documented in
message-formats.md as a recommended shape; it is not implemented.