LangGraph¶
Available · tool nodes, interrupt approvals, compiled flows · pip install "agent-adapter[langgraph]" (langgraph, langgraph-checkpoint)
Registry tools run inside LangGraph through an Executor, so hooks, approval, timeouts,
retries, the cache and the ledger apply (executor=None uses the default executor with
LangGraphApproval() as its port). A Flow compiles to a
StateGraph that behaves like the local runner.
Functions¶
Function (agent_adapter.integrations.langgraph) |
Returns |
|---|---|
tool_node(registry, *, executor=None, messages_key="messages", concurrency=None) |
a node running the last AIMessage's tool calls; .tools are the definitions for bind_tools |
tool_task(spec, *, executor=None, into) |
a Send target: runs spec with the Send argument, returns {into: value} |
fan_out(target, over) |
a conditional-edge path: one Send(target, item) per mapping in state key over (or over(state)) |
reducer(rule) |
a channel for Annotated[T, reducer(Merge.append)] that sees every write of a superstep |
LangGraphApproval(key=None) |
an ApprovalPort that pauses the graph with interrupt(); .resume_value(interrupt, decision) signs an answer |
one_use(node) |
your own node (sync or async, with or without config), run so each approval answer is used once; required around a node that calls LangGraphApproval itself |
compile_flow(flow, *, executor=None, checkpoint_key=None) |
a StateGraph; its compile() adds the gated nodes to interrupt_before |
async run_compiled(app, state, config) |
a FlowResult (done, paused, denied; checkpoint=None) |
langgraph_compressor(policy) |
a compressor for your agent node: the model gets a shorter list, the state keeps the full history (Compression by framework) |
INTEGRATION |
export_tool(spec) (a one-tool tool_node), bind_context(tool_call), compile_flow |
Tool node¶
port = LangGraphApproval(key=b"a shared secret of 16+ bytes") # signs resume values
executor = Executor(registry, approval=port) # approvals pause the graph
graph = StateGraph(MessagesState)
graph.add_node(tool_node(registry, executor=executor)) # a node named "tools"
graph.add_edge(START, "tools")
graph.add_edge("tools", END)
app = graph.compile(checkpointer=InMemorySaver())
calls = [
{"name": "shipping_rate", "args": {"item": "laptop"}, "id": "c1"},
{"name": "send_statement", "args": {"period": "2026-09"}, "id": "c2"},
]
config: Any = {"configurable": {"thread_id": "shop-1"}}
out = await app.ainvoke({"messages": [AIMessage("", tool_calls=calls)]}, config, version="v2")
[interrupt] = out.interrupts # shipping_rate ran; send_statement waits for a reviewer
request = interrupt.value["request"]
print("asks", request["tool"], request["arguments"])
answer = port.resume_value(interrupt, ApprovalDecision(approved=True)) # bound and signed
out = await app.ainvoke(Command(resume={interrupt.id: answer}), config, version="v2")
print([m.content for m in out.value["messages"][1:]])
| What | How |
|---|---|
| Call id | the tool call id (ctx.call_id); a call without one gets <task namespace>:<index> |
| Run id | current_context() inside an adapter run, else configurable["run_id"], else ["thread_id"] (also ctx.thread_id) |
| Order | calls whose confirm rule says yes run one at a time after the others (concurrency caps the others), so each resume answers one interrupt |
Model-visible errors (ToolArgsInvalid, ToolError, ApprovalDenied, ...) |
an error ToolMessage (status="error", content {"error": {"kind", "message"}}); also unknown tools and duplicate call ids |
Other errors (ToolTimeout, ToolFailed, ApprovalPending from a deferred port) |
raised from the node |
GraphInterrupt (GraphBubbleUp), CancelledError |
always propagate, unwrapped, through every executor method |
Approvals and resume values¶
Every interrupt value is {"type": "agent_adapter.approval", "binding", "request"}. The
binding is the SHA-256 of the request's run id, tool, kind, request_digest, call id and
(for a store's record) approval id. Resume with port.resume_value(interrupt, decision)
under the interrupt's id, as the tool node example does: it adds mac, an HMAC-SHA256 of
the binding and the decision under the port's key.
| Resume value | Result |
|---|---|
| bound to this request and signed with the port's key | the decision applies (edited_args run), once per task run (a task run: a tool node, a tool task, a compiled node, or your node wrapped in one_use) |
a bare decision, another binding (run, tool, arguments, call), no or a wrong mac |
LangGraph's invalid resume: ValidationError (is_invalid_resume), nothing saved, answer again |
| the same answer again: two concurrent resumes, a replay of the paused checkpoint after the run went on | ApprovalAlreadyDecided; the tool runs once |
| Port | Who decides |
|---|---|
LangGraphApproval(key=None) |
the signed resume value. key=None is a random per-process key: the run then resumes only in that process; give every process the same key |
a port with a store (DeferredApproval) |
the store only; a resume value never decides. Decide the record (or call run_compiled(app, decision, config), which does), then continue with None. A record used once is never used again |
run_compiled(app, ApprovalDecision(...), config) picks the right path. Each answer is used
once per task run in a process. With LangGraphApproval(key=K) shared across processes, a
resume value can be replayed in another process after the run went on (that process has
no record of its use): use a store-backed port when that matters.
In your own node, wrap the node with one_use(node): LangGraphApproval outside one raises
ToolUsageError before it asks. LangGraph re-runs your node from the top, so your code
after an answer runs again:
In a one_use node |
Result |
|---|---|
| a re-run in the same process | executor calls finished before the pause replay their results (give each a stable call_id); their answers are claimed before your code runs |
the re-run fails (any error; a finished result the journal cannot hold raises ToolUsageError) |
the host's retry raises ApprovalAlreadyDecided: an approved tool runs once |
a call whose arguments cannot be journaled (e.g. a pathlib.Path argument), then a pause |
cannot pause: ToolUsageError (the call would run again), and the retry raises ApprovalAlreadyDecided. Give that call its own node or use compile_flow |
a raw interrupt() |
shifts the resume positions: it may get an earlier signed answer as its value, or every answer is an invalid resume. No approval goes to another call (binding, MAC), but fork the thread. Ask through LangGraphApproval only |
| a new process, or an evicted journal (10 000 paused nodes, 24 h) | the finished calls run again (as in tool_node): when that matters, use compile_flow (its journal is sealed into the interrupt value) or one confirm call per node |
A failure before the executor uses the answer (a before_tool hook, a store outage while
the answer is settled) keeps the answer: retry with run_compiled(app, None, config) (or
ainvoke(None)). A failure after it used the answer (the approval step passed, whatever
context or sink the call ran with) spends it: the retry raises
ApprovalAlreadyDecided. To go on anyway, fork the thread from an earlier checkpoint
(ainvoke(..., checkpoint_config) asks for a new approval) or write the result yourself with
app.aupdate_state(config, {...}, as_node=<node>) and continue. A run cancelled while the
tool runs saves nothing: with a store-backed port the retry raises ApprovalAlreadyDecided;
with LangGraphApproval it asks again and the tool may already have run (check first).
Write access to the checkpointer is not approval authority, but it holds the state and the
interrupt values, and those carry the requests' arguments and the finished calls' results
(possibly personal data) to anyone who can read the checkpointer or the __interrupt__
stream. Use LangGraph's EncryptedSerializer and restricted database access, and never
forward raw interrupt values to a UI: show reviewers value["request"] only.
Compiled flows¶
class Order(BaseModel):
items: list[dict[str, Any]]
rates: Annotated[list[float], Merge.append] = Field(default_factory=list)
receipt: str = ""
def place(state: Order) -> dict[str, Any]:
return {"receipt": f"placed with {len(state.rates)} rates"}
flow = (
Flow("order", state=Order)
.node("rate", Map("items", tool="shipping_rate", into="rates"))
.node("place", place, pause=lambda s: {"rates": s.rates})
.edge("rate", "place")
.end("place")
)
store = MemoryApprovalStore()
executor = Executor(registry, approval=DeferredApproval(store))
app = compile_flow(flow, executor=executor).compile(checkpointer=InMemorySaver())
config = {"configurable": {"thread_id": "order-1"}}
paused = await run_compiled(app, {"items": [{"item": "a"}, {"item": "b"}]}, config)
assert paused.pending is not None
print(paused.status, paused.pending.tool, paused.pending.arguments)
done = await run_compiled(app, ApprovalDecision(approved=True), config)
print(done.status, done.steps, dict(done.visits), done.state.receipt)
flowchart LR
F["Flow"] --> V["flow.validate()"] --> C["compile_flow"]
C --> N["one node per flow node<br/>(the local runner's node visit)"]
C --> CH["one channel per field<br/>(its merge rule)"]
C --> G["gates: interrupt_before"]
N & CH & G --> SG["StateGraph"] --> CP["compile(checkpointer)"] --> R["run_compiled"]
| Flow concept | LangGraph |
|---|---|
node (fn, Call, Map, Join) |
one node running the local runner's visit: hooks, timeout, retries; Map calls executor.run_many |
static edges / Route |
add_edge / Command(goto=...) from the node; the route sees the input state plus its own writes |
Join |
defer=True |
| merge rules | a channel per field applying the rule to all writes of a superstep, in node-name order then item order |
| loop caps, visits | reserved keys aa_loop_counts, aa_visits |
pause gate |
interrupt_before the gated nodes: nothing of the superstep runs until the gate is decided |
| tool approval in a node | a pause; answered by the store or a signed resume value bound to run, tool, arguments and (for Call / Map) the call id |
max_steps |
recursion_limit (run_compiled defaults it to 100); GraphRecursionError → FlowLoopExceeded |
| checkpoint | the LangGraph checkpointer (FlowResult.checkpoint is None); treat its storage as trusted |
run_compiled takes the initial state, an ApprovalDecision for what is pending, a
Command, or None to continue (for example after a decision made in the store). A gate
whose port decides at once does not stop the run. A denied gate ends the run without
running its superstep; a gate's grant is used once (a spent one → ApprovalAlreadyDecided;
gates and siblings: see below). Node names __start__ / __end__ → FlowCompileError.
Re-runs¶
LangGraph re-runs an interrupted node from the top. A paused node seals its progress into
the interrupt value (aa_state: the approvals it surfaced and how each was answered, the
replay journal, finished Map items), signed with compile_flow(..., checkpoint_key=)
(default: the LangGraphApproval port's key, else a per-process key) and bound to the run,
the task and the graph. A re-run in any process with that key reads it back from the
checkpointer and replays finished calls as the local runner does: each call runs once,
the ledger has one entry per call and once_per_run holds, also for a node function's own
calls. A sealed state that does not verify (another key), or resume values whose sealed
state is gone, fail closed with FlowError and run nothing. An approved tool that failed
is not run again by a retry (its answer was used). Interrupt values are signed, not
encrypted (see above).
Differences from the local runner¶
| Case | Local runner | LangGraph |
|---|---|---|
every case of tests/flow/parity.py |
reference | equal (status, state, visits, steps, pauses) |
| resume in a new process | the sealed checkpoint replays finished calls | the sealed interrupt value replays them (same key) |
siblings of a gate with edited_args |
see the edited state | see the state before the edit |
| several gates in one superstep | asked one by one, nothing runs | the same with a store-backed port; ToolUsageError without a store |
| a fork of an old checkpoint (time travel) | — | asks again (a new record); a used answer is refused |