Repository navigation
LangGraph: prebuilt nodes that are Restate steps, and langgraph.agent(graph=...) - #288
Conversation
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).
There was a problem hiding this comment.
ℹ️ No critical issues — one suggestion inline.
Reviewed changes
Reviewed the whole PR: the new restate.agents.langgraph.nodes module, the helper renames in _tools.py, and the Restate-server tests.
- Prebuilt nodes:
wait_for_signal,workflowandsubagenteach make one Restate call. The idempotency key comes from LangGraph's task namespace,<node>:<task_id>, and the task id is derived from the checkpoint id. - Guards:
_steprefuses to run without the handler's context (ainvoke(context=ctx)) or without a checkpointer. - Shared helpers:
_untyped,_inputs,_handlerand_input_typein_tools.pydrop their leading underscore. No old names are left anywhere. - Tests: the end-to-end graph, plus two crash-in-a-parallel-branch tests that check the step is joined rather than repeated.
ruffandpyrightpass locally on the touched files.
claude-opus-5-5 | 𝕏
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.
There was a problem hiding this comment.
✅ No new issues found. The durability concern from the last review is fixed.
Reviewed changes
Reviewed the one commit since the last Pullfrog review, which makes the nodes require durability="sync".
- Durability guard:
_stepnow raisesTypeErrorunless__pregel_durabilityis"sync". It rejects a missing value (LangGraph defaults to"async"),"async"and"exit". Subgraphs get the parent's value throughconfigurable, so the guard also applies inside them. - Docs: the module docstring explains that
durability="sync"stores each step's checkpoint before the step runs. - Test: the new
test_a_node_refuses_to_run_unless_each_step_is_stored_before_it_runsruns the graph with no durability,"async"and"exit". It passes locally, as does the workflow-input test.
claude-opus-5-5 | 𝕏
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.
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
Reviewed the one commit since the last Pullfrog review. It adds an example that uses the nodes in a graph, plus a test for it.
refund_graph.pyexample: a graph with three steps.checkasks apolicysubagent,approvewaits for anapprovalsignal, andpayruns thepayoutsworkflow only if the refund is approved. The handler resumes a run that a crash cut off, usingaget_state(config).next, and runs the graph withdurability="sync", as the durability guard requires.- Example test:
test_intermediate_refund_graphputs a scriptedVirtualObject("policy")in place of the model-backed agent before serving. This works becausenodes.subagentfinds its target byagent.namewhen it runs. The test checks whatpendingshows, then sends the signal, for one approved refund and one rejected refund. - README: added a row for the new example.
ruff check and ruff format --check pass on both files. Pyright could not check them here because this runner doesn't have langgraph or pytest installed.
claude-opus-5-5 | 𝕏
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.
There was a problem hiding this comment.
Important
One problem to fix before merging: after a stop, a create_agent-style graph can be left unable to answer any later message on that key. Details inline.
Reviewed changes
Reviewed the one commit since the last Pullfrog review. It adds langgraph.agent(name, graph=...) and changes the refund example into a chat agent.
- Graph agent:
_graph.py'srun_ofruns the graph on the key's"chat"thread. Checkpoints go inSqlCheckpointSaver(ctx.db, history="latest")and the run usesdurability="sync". The prompt goes in as aHumanMessagewhose id isprompt:<prompt id>, so a retried attempt can tell whether to resume the run, reuse its finished answer, or start a new one. - Conversation copy: after each streamed state,
_copiedadds to the conversation, by id, the AI and tool messages after the prompt that it doesn't have yet, then publishes them. The answer is the last AI message, andoutput=validates it throughanswer_with. - Argument checks:
agent()raisesTypeErrorifgraphis passed withmodel,systemortools, or if the graph's state has nomessageschannel. - Example and tests:
refund_graph.pyis now a chat desk with a clerk subagent (output=Refund) and ananswernode.tests/agents_langgraph_graph.pycovers the graph agent. The two in-process tests pass locally (-k "streams or own_model").ruffandpyrightare clean on the touched files.
claude-opus-5-5 | 𝕏
There was a problem hiding this comment.
✅ No new issues in this commit. The finding from the previous review, about unanswered tool calls after a
stop(_graph.py:121), still applies.
Reviewed changes
Reviewed the one commit since the last Pullfrog review. It makes nodes.workflow validate the input it takes from the state into the handler's input type.
- Input validation:
_validatorbuilds aTypeAdapterfor the handler's input type when the node is built, and skips it forNoneandAny. When the node runs, it validatesinput(state). An instance of the target type passes through unchanged. AValidationErrorbecomes aTerminalErrorthat names the handler, so the node fails instead of retrying on the same state. Building aTypeAdapterup front is what Restate tools already do (_tools.py:345). - Example:
refund_graph.pypasses therefunddict from the state directly intopay. - Tests: the new
invoiceandbad_invoicegraphs check that a dict is validated intoInvoice, and that an invalid amount fails with the handler's name without running the workflow.
claude-opus-5-5 | 𝕏
…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.
There was a problem hiding this comment.
ℹ️ No critical issues. The earlier finding about unanswered tool calls after a
stopis fixed. One minor suggestion inline.
Reviewed changes
Reviewed the one commit since the last Pullfrog review, 203c090.
- Closing the previous run:
_closingputs whatclose()added to the conversation at the start of the next run's input. That is the error results of cut-off tool calls and the closing note, each with its ownclosed:<n>id so it is only carried once. When the graph never stored the previous run's prompt, that prompt is carried as well. With these results in the graph's state, every tool call has a result, so the model provider no longer refuses the history after astop. LangGraph's_firstdrops the pending tasks when it gets this input. nodes.callandnodes.send: these call or send to a handler through the shared_targetcheck.key=is required exactly when the handler is keyed. The input is validated the same way as innodes.workflow.sendalso takes adelay._tools._keyedis renamed tokeyed.- Tests: new tests cover the graph seeing
CANCELLEDafter a stop, a cut-off tool call getting a result exactly once, a call to an object followed by a send to a service, and the key argument checks.ruffpasses, and the in-process tests pass locally (-k "takes_a_key or takes_input or refuses or streams or own_model").
claude-opus-5-5 | 𝕏
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.
There was a problem hiding this comment.
✅ No new issues in this commit. One earlier suggestion is still open. It is about
_closingcarrying back a previous prompt that the graph removed (_graph.py:136), and that code hasn't changed.
Reviewed changes
Reviewed the one commit since the last Pullfrog review, f7b518c, which applies fixes from a review of the whole PR.
prompt(graph=...):langgraph.promptnow takes agraph. It runsrun_ofthroughanswer_once, andanswer_withwhenoutputis given. The graph agent'schatnow just calls it._check_graphholds the model, system and tools check, soagent()andprompt()share it._copiedscope: to find the messages already copied,_copiednow looks only at the conversation entries after the running prompt. A run's copies are only ever added after its own prompt, and theclosed:<n>items carried from the last turn come before the prompt, so_afternever returns them.- Input types Pydantic doesn't know:
_validatorcatchesPydanticSchemaGenerationError(from a msgspecStruct, for example) and passes the value through unchanged, so the handler's serde handles it. wait_for_signalinput as JSON: theinputvalue goes throughto_jsonable_pythonbefore the signals companion stores it. Models in the state now show inpendingas JSON.- Tests and docs: the new tests cover a custom handler that prompts the graph, a model as the signal's input, and a msgspec handler input. A new
untilhelper puts a 20s limit on the crash tests' busy-waits. Thenodesdocstring now starts withlanggraph.agent(graph=...). The new in-process msgspec test passes locally, along with the argument-check tests.ruffis clean, andpyrightreports 0 errors onagents/langgraph/.
claude-opus-5-5 | 𝕏
There was a problem hiding this comment.
✅ No new issues found. The earlier suggestion about
_closingbringing back a prompt that the graph removed is fixed. I resolved that thread.
Reviewed changes
Reviewed the one commit since the last Pullfrog review, 73c9121. With it, a graph that trims its history, including the run's input and answer, keeps working.
- Finding a run by checkpoint metadata:
run_ofputsrestate_prompt: prompt:<id>in the run'sconfig["metadata"].askednow reads that key from the latest checkpoint's metadata, instead of looking for the prompt'sHumanMessagein the state. I checked locally that LangGraph'sget_checkpoint_metadataadds the key to every checkpoint, the input checkpoint included, so a retried attempt still recognizes the run it cut off. - Copying by id across the whole state:
_copiednow looks at every message in the state, not only those after the prompt (_afteris gone). It skips ids already in the conversation and ids with theprompt:orclosed:prefix. The ids it copies go intoknownas it goes.clearempties the key's database, checkpoints included, soknownand the graph's state stay in step. - Answer from the conversation:
_answerreturns the last AI message with an id that the run copied after its prompt. The closing notes fromclose()have no id, so they are never taken as an answer. _closing: the previous prompt is carried back only when its run copied nothing. A run that copied something had stored its prompt.- Test:
test_a_message_the_graph_trimmed_is_not_brought_backuses atrimnode that removes every message, then ahumansnode that checks the old prompt didn't come back. The in-process tests pass locally (the others need Docker).ruff check,ruff format --checkandpyrightare clean on the touched files.
claude-opus-5-5 | 𝕏
The test assumed that b ran in the first attempt, beside c, which fails. When c fails before b starts, b runs in the retry instead (seen on CI, Python 3.13). Either way a and b run once, c twice, and d last, which is what the test now checks.

This PR adds two things for LangGraph graphs of your own.
Prebuilt nodes that are Restate steps
nodes.wait_for_signal(signal, output=, input=)pendingview lists the wait, withinputconverted to JSON, and itssignalview resolves it.nodes.workflow(handler, output=, input=, key=)nodes.call(handler, output=, input=, key=)nodes.send(handler, input=, key=, delay=)nodes.subagent(agent, message=, output=, key=, handler="chat")<this key>.<node name>.How the nodes behave:
TypeAdapter. A value that already has that type passes through. A type Pydantic doesn't know, such as a msgspecStruct, goes to the handler's serde unchanged. Input that doesn't validate fails the node with a terminal error, instead of an attempt that Restate retries forever.keymust be given exactly when the handler is keyed, andinputexactly when it takes one; otherwise building the node raises aTypeError.ainvoke(context=ctx)), or with any durability other than"sync". With other durabilities, a crash could recreate a step's checkpoint under a new id, and so a new key.langgraph.agent(name, graph=...):chatruns your graphHumanMessage. The graph has its own model and tools, somodel,systemandtoolsare refused.messages,sync,watchand the chat CLI show the run as it goes.durability="sync", LangGraph streams a step's state only once its checkpoint is stored. A test checks this ordering against a deliberately slow checkpointer, and the test fails under"async".output=validates it; with your own graph, it's up to the graph's own prompts to ask for the JSON.Example
examples-agents/intermediate/refund_graph.pyis a refund desk you chat with:langgraph.agentwithoutput=Refund, reads the request.tests/examples_agents.pyserves it with scripted agents, and checks both the approved and the rejected path.Tests
tests/agents_langgraph_nodes.pyandtests/agents_langgraph_graph.pyrun against a Restate server:callandsend;prompt(graph=), andoutput=;chat;stop, and what carries over after it, both its note and a cut-off tool call. These tests fail if the carry-over is removed;The helpers that the tools and the nodes share in
_tools.pydrop their leading underscore.