Skip to content

LangGraph: interrupt() as a Restate signal, langgraph.tools(), and create_agent - #289

Merged
igalshilman merged 16 commits into
mainfrom
langgraph-interrupts
Nov 6, 2026
Merged

igalshilman merged 16 commits into
mainfrom
langgraph-interrupts

Conversation

@igalshilman

@igalshilman igalshilman commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

Follows #288, now merged.

This PR makes LangGraph graphs of your own, including LangChain's create_agent, work on langgraph.agent(graph=...) without changes.

interrupt() waits for a Restate signal

Before this PR, a graph that called interrupt() stopped, and chat answered with an empty string. Every later message hit the same interrupt again.

def approve(state):
    decision = interrupt({"refund": state["refund"]})  # waits for: curl .../signal --json '{"name": "interrupt", "value": "yes"}'
    ...
  • The wait: each pending interrupt becomes a wait named "interrupt". The agent's pending view lists it with the interrupt's value, and its signal view resolves it, by the wait's id or as the oldest "interrupt". The run then resumes with Command(resume=...).
  • Durability: the turn waits without holding a process. When Restate suspends it, the resumed attempt rejoins the same waits. A test checks the suspension through Restate's admin API.
  • Telling interrupts apart: a node that interrupts twice gets the same interrupt id from LangGraph both times. So a wait's id is the interrupt's id plus the number of resume values its task already has. For an interrupt inside a subgraph, that count is read from the subgraph's own checkpoint.
  • Parallel branches: interrupts in parallel branches wait at the same time.

langgraph.tools([...])

Our tools as LangChain tools, for a graph of your own or for create_agent:

graph = create_agent(model, tools=langgraph.tools([call(order_status, description="An order's status.")]))
desk = langgraph.agent("desk", graph=graph)
  • Context: the tools take the handler's context from LangGraph's run context. langgraph.agent(graph=...) passes it; a handler of your own passes context=ctx.
  • Each step once: the tools that are Restate's (call, send, workflow, subagent, wait_for_signal) make their Restate call once per tool call, because the model's reply, with the tool call ids, is stored before the tools run.
  • What still runs again: a tool of your own runs your function, and LangChain tools pass through unchanged, so both run again after a crash.

create_agent's structured response

  • A graph that writes a structured_response during the run answers with it, as JSON, and output= validates it.
  • One left in the state by an earlier run is not the answer. "Written during the run" means the channel's version changed; the version from before the run is kept in the run's checkpoint metadata.

Also in this PR

  • Version cap: the langgraph extra is capped below LangGraph 2 (and langchain-core below 2). The nodes rely on LangGraph's checkpoint and config internals. The lockfile change is just those two lines.
  • Docs: the package docs show create_agent with our tools, and have a "Good to know" section:
    • durability is per step, so side effects belong in Restate nodes or tools;
    • changing a graph while runs wait is a migration;
    • long conversations store their messages again at each step;
    • there is no token streaming.
  • New example: examples-agents/basic/create_agent.py.

Tests

All run against a Restate server:

  • Interrupts:
    • a single interrupt;
    • two in one node, in the graph and in a subgraph;
    • two parallel branches;
    • a suspended turn.
  • create_agent:
    • with a Restate tool;
    • with response_format;
    • a structured response from an earlier run, and the same one written twice.
  • Nodes: a node in a subgraph cut off by a crash, and a Send fan-out.
  • The example: run with a scripted model standing in for the OpenAI one.

The subgraph-interrupt and stale-structured-response tests fail without their fixes.

Not covered yet:

  • a crash in the middle of a create_agent tool call;
  • tools() in a graph that a handler of your own runs;
  • a real model: this was only run against scripted models.

nodes.wait_for_signal, nodes.workflow and nodes.subagent make nodes for a
graph of your own, as the tools do for an agent: each reads its input from
the state and writes what it gets back to the state field `output`.

The graph runs in a handler of an AgentObject, with ainvoke(context=ctx) and
SqlCheckpointSaver(ctx.db). Each node's idempotency key is LangGraph's task
namespace, which LangGraph derives from the checkpoint the step started from:
a node re-run after a crash gets the same one, so it joins the wait, the
workflow or the other agent's answer it started before. A wait is listed by
the agent's pending view, and its signal view resolves it.

The helpers the tools and the nodes share in _tools.py lose their leading
underscore (handler_of, input_type_of, inputs_of, untyped).
LangGraph's default durability, "async", only submits a step's checkpoint
save, and the next step's tasks start right away ("exit" saves nothing
between steps). A crash before the save makes the resumed run create the
checkpoint again with a new id, so the node gets a new task id and a new
idempotency key, and makes its step twice. Only "sync" stores the checkpoint
before the step runs.
examples-agents/intermediate/refund_graph.py runs a graph of your own in a
handler of an AgentObject: it asks a policy agent about the refund
(nodes.subagent), waits for a manager's approval (nodes.wait_for_signal),
routes on the answer with an ordinary conditional edge, and runs the payout
workflow (nodes.workflow).

tests/examples_agents.py serves it with a scripted policy agent, and checks
both an approved refund, which is paid, and a rejected one, which ends unpaid.
The graph's state has LangGraph's messages (MessagesState); a StateGraph or a
compiled graph (such as LangChain's create_agent makes) is accepted, with no
model, system or tools of ours. Each message is a run of the graph on the
key's thread, with the message as a new HumanMessage whose id is the prompt's:

- The checkpoints are in the key's database (SqlCheckpointSaver, history
  "latest"), stored before each step (durability "sync"), and the run's
  context is the handler's, so the prebuilt nodes work in it. A retried
  attempt resumes the run it cut off, or takes the finished run's answer.
- After each step, the AI and tool messages it added are copied into the
  agent's conversation by id, so the messages, sync and watch views show the
  run as it goes. LangGraph streams a step's state only once its checkpoint is
  stored, so the conversation never gets ahead of the checkpoints; a copy a
  crash cut off is made at the next step or at the end. A test checks that
  ordering.
- The answer is the run's last AI message; output= validates it.
- After a stopped run, the next message starts a run of its own.

The refund example becomes a desk you chat with: a clerk agent (output=Refund)
reads the request, a policy agent checks it, a manager approves, and the
payout workflow runs.
… input

Graph state is plain data, while a workflow's handler often takes a Pydantic
model (or a dataclass, or a TypedDict). A dict from the state was passed
through as it was, so the handler's serde failed on every attempt and
Restate retried forever. The node now validates the value with Pydantic's
TypeAdapter into the handler's input type; a value of that type already is
passed through. A value that does not validate fails the node with a terminal
error naming the handler and the field, since a retry would read the same
state.

The refund example passes the state's dict again.
…nodes

The graph agent: when a message starts a run after one that was stopped,
refused or failed, the run's input starts with what closed that run in the
conversation: the results of its cut-off tool calls, so that the graph's
history has no tool call without a result (which a model provider refuses),
and the note that ends it, so that the graph's model knows how the turn
ended, as the built-in agent's does. The previous message comes too, if the
graph never stored it. Each carried item has an id of its own, so it is
carried once.

nodes.call and nodes.send call or send to a handler, as the tools do. A key is
required exactly for a keyed handler (a virtual object's or a workflow's),
and plain data from the state is validated into the handler's input, as for
nodes.workflow. _tools.keyed is shared with them.
- nodes: a handler input type Pydantic does not know (a msgspec Struct) no
  longer makes building the node fail: the value goes to the handler's serde
  as it is.
- nodes.wait_for_signal: what the graph waits for is turned into JSON, so a
  model in the state (a workflow's result, say) no longer fails each attempt.
- prompt(ctx, text, graph=...): a handler of your own prompts the graph as
  chat does; agent(graph=...) answers through it.
- The graph agent copies a run's messages looking at the run's own entries
  only, not the whole conversation at each step.
- Docs: nodes lead with langgraph.agent(graph=...); output= with a graph is
  the graph's own prompts' to ask for; the example does not claim the chat
  shows steps that add no messages.
- Tests wait at most 20 seconds for their events, so a broken one fails
  rather than hangs; new tests for the above.
A run was found by its prompt's place in the graph's state, and its messages
and answer were those after it. A graph that trims its history (a summarizing
one) removes the prompt as well, and the run then copied nothing and answered
with nothing; and the next message brought the trimmed prompt back.

Each checkpoint of a run now names its prompt in its metadata, which is how a
retried attempt finds the run it cut off. The messages copied are the graph's
AI and tool messages that the conversation does not have (not those it gave
the graph), and the answer is the last AI message the run copied, read from
the conversation, which is a log. The previous prompt is carried into a new
run only if its run copied nothing, so a prompt the graph removed is not
brought back.
…d response

LangGraph's interrupt() stopped the run, and chat answered with nothing. Each
pending interrupt is now a wait named "interrupt", which pending lists with
the interrupt's value and signal resolves; the run then resumes with
Command(resume=...). The turn waits without holding a process, and a
suspended or retried attempt joins the same waits. A node that interrupts
twice has the same interrupt id both times, so a wait's id is the
interrupt's and the number of resume values its task already has.
Interrupts of branches at once are waited for at once.

A graph whose state has a structured_response (create_agent with a
response_format) answers with it, as JSON, which output= validates.

Tests: an interrupt, two in one node, two branches at once, and a turn that
Restate suspends while it waits (checked through the admin API).
… as create_agent's

The restate.agents tools (tool, call, send, workflow, subagent,
wait_for_signal) as LangChain tools, which take the handler's context from
the graph's run (LangGraph's run context), so they work in
langgraph.agent(graph=...) and in a graph a handler of your own invokes with
context=ctx. Their Restate calls are made once per tool call.

The package docs show create_agent with these tools, and say what to know
about a graph of your own: durability is per step, so side effects belong in
Restate nodes or tools; changing a graph that has waiting runs is a
migration; long conversations store their messages at each step; there is
no token streaming.

Tests: create_agent with a Restate tool, and with a response_format.
…d fan-out

A subgraph's node rejoins its step after a crash in the parent graph, and the
same node sent to three times at once makes a step for each.
restate.agents.langgraph relies on LangGraph's checkpoint and config
internals (task ids, durability), which a major version may change.
…tool

examples-agents/basic/create_agent.py runs create_agent with a call to an
orders service as its tool (langgraph.tools), as langgraph.agent(graph=...).
Its test stands a scripted model in for the OpenAI one, and checks that the
tool call reaches the service.
- An interrupt inside a subgraph: its resume values are among the pending
  writes of the subgraph's own checkpoint, so a subgraph's node that
  interrupted twice joined its first wait again. The pending interrupts are
  now found down into subgraphs, each counted at the task that raised it.
- A structured_response is the answer only of the run that wrote it (the
  version of its channel changed in the run), not one an earlier run left in
  the state; the same response written again is still the answer. The
  version before the run is in the run's checkpoints' metadata, for a
  retried attempt.
- tools(): only the tools that are Restate's (call, send, workflow,
  subagent, wait_for_signal) make their step once; a tool of your own runs
  your function again after a crash, as the docs now say.

Tests for the first two, which fail without the fixes.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

The ids of interrupt waits skip the companion's cleanup for turns that end without closing their waits. A killed turn leaves a stale "interrupt" wait behind, and later by-name signals resolve that stale wait instead of the live one.

Reviewed changes

Initial review of the whole PR: interrupt() as a Restate signal, langgraph.tools(), and create_agent structured responses on langgraph.agent(graph=...).

  • Interrupts as waits: run_of resumes a stopped run with Command(resume={interrupt_id: value}). The values come from _signals.wait calls whose ids include each task's resume count, and the count is read from the subgraph's own checkpoint for an interrupt inside a subgraph.
  • Structured response: _answer answers with structured_response as JSON only when its channel version changed during the run. The version from before the run is kept in checkpoint metadata, so retries keep it.
  • langgraph.tools(): wraps restate.agents tools as _RestateTool with no bound context. They take the handler's context at call time from get_runtime().context.
  • Version cap: the langgraph extra is capped below LangGraph 2 and langchain-core 2.
  • Docs, example, tests: a "Good to know" section, examples-agents/basic/create_agent.py, and Docker-harness tests for interrupts, create_agent, a subgraph crash, Send fan-out, and the example.

I checked the resume-count logic against LangGraph 1.1.9's interrupt() and _loop.py. A resume map is applied through CONFIG_KEY_RESUME_MAP, and each interrupt() writes its task's full __resume__ list. So len(values) is the right count, and a retry rejoins the same wait id.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using claude-opus-5-5 | 𝕏

@github-actions

github-actions Bot commented Nov 6, 2026 •

Copy link
Copy Markdown

Test Results

  8 files  ±0    8 suites  ±0   3m 56s ⏱️ +33s
 71 tests ±0   71 ✅ ±0  0 💤 ±0  0 ❌ ±0 
322 runs  ±0  322 ✅ ±0  0 💤 ±0  0 ❌ ±0 

Results for commit a2da8cb. ± Comparison against base commit 847d3ef.

♻️ This comment has been updated with latest results.

The companion closes the waits of a turn that ends without them (killed, or
out of retries) by their ids, which start with the turn's invocation id, as
the tools' waits do. The waits of interrupts and of nodes.wait_for_signal
started with "langgraph:", so a killed turn left them pending, and a later
signal by name resolved the stale wait instead of the live one.

Their ids now start with the invocation id, which is the same in each attempt
and suspension of a turn, so a retried attempt still joins the same wait. The
other nodes keep their keys: a workflow or a call must stay once even when a
later invocation resumes the graph.

A test kills a turn waiting on each, and checks pending is empty; it fails
with the old ids.
Base automatically changed from langgraph-nodes to main November 6, 2026 20:04
# Conflicts:
#	python/restate/agents/langgraph/__init__.py
#	python/restate/agents/langgraph/_graph.py
#	python/restate/agents/langgraph/nodes.py
#	tests/agents_langgraph_graph.py
#	tests/agents_langgraph_nodes.py

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found. The stale-wait finding from the prior review is fixed.

Reviewed changes

This run covers the changes since the review at b7d0eb0: one fix commit, and a merge of main now that #288 is squash-merged. The merge's combined diff is empty. It only brings in main's own change to tests/agents_langgraph_checkpointer.py.

  • Interrupt waits close with their turn: in _resumed, interrupt wait ids are now {ctx.request.id}:interrupt:{interrupt id}:{resume count}. The id starts with the turn's invocation id, which is what _signals._closed filters on when _turn.py closes a turn. The id is still the same in every attempt, so a retry rejoins the same wait.
  • nodes.wait_for_signal waits close too: its wait id and idempotency key are now {step.ctx.request.id}:{step.idempotency_key}. workflow, call, send and subagent keep their langgraph:{key}:{namespace} keys on purpose, so they stay exactly-once when a later invocation resumes the graph.
  • Test: added test_a_killed_turn_leaves_no_wait_behind, which runs for an interrupt (ask) and for a wait_for_signal node (refund). It kills the turn through the admin API and checks that pending empties.

Pullfrog  | View workflow run | Using claude-opus-5-5 | 𝕏

@igalshilman
igalshilman merged commit 8ad9fe2 into main Nov 6, 2026
11 checks passed
@igalshilman
igalshilman deleted the langgraph-interrupts branch November 6, 2026 20:16
@github-actions github-actions Bot locked and limited conversation to collaborators Nov 6, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant