dex-agents-langgraph 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,105 @@
1
+ # License
2
+
3
+ Copyright (c) 2026 Super Durable, Inc.
4
+
5
+ Portions of this software are licensed as follows:
6
+
7
+ - Legacy Materials remain under their original licenses as described in
8
+ `LEGACY_NOTICES.md`.
9
+ - Third-Party Materials remain under the licenses provided by their owners.
10
+ - The `docs/` and `examples/` directories are not licensed under the
11
+ Sustainable Use License and retain their existing licensing status.
12
+ - All other material owned or licensable by Super Durable, Inc. is available
13
+ under the Sustainable Use License below.
14
+
15
+ See `LEGACY_NOTICES.md` for the applicable legacy and third-party notices.
16
+
17
+ ## Sustainable Use License
18
+
19
+ Version 1.0
20
+
21
+ ### Acceptance
22
+
23
+ By using the software, you agree to all of the terms and conditions below.
24
+
25
+ ### Copyright License
26
+
27
+ The licensor grants you a non-exclusive, royalty-free, worldwide,
28
+ non-sublicensable, non-transferable license to use, copy, distribute, make
29
+ available, and prepare derivative works of the software, in each case subject
30
+ to the limitations below.
31
+
32
+ ### Limitations
33
+
34
+ You may use or modify the software only for your own internal business purposes
35
+ or for non-commercial or personal use. You may distribute the software or
36
+ provide it to others only if you do so free of charge for non-commercial
37
+ purposes. You may not alter, remove, or obscure any licensing, copyright, or
38
+ other notices of the licensor in the software. Any use of the licensor's
39
+ trademarks is subject to applicable law.
40
+
41
+ ### Patents
42
+
43
+ The licensor grants you a license, under any patent claims the licensor can
44
+ license, or becomes able to license, to make, have made, use, sell, offer for
45
+ sale, import and have imported the software, in each case subject to the
46
+ limitations and conditions in this license. This license does not cover any
47
+ patent claims that you cause to be infringed by modifications or additions to
48
+ the software.
49
+
50
+ If you or your company make any written claim that the software infringes or
51
+ contributes to infringement of any patent, your patent license for the software
52
+ granted under these terms ends immediately. If your company makes such a claim,
53
+ your patent license ends immediately for work on behalf of your company.
54
+
55
+ ### Notices
56
+
57
+ You must ensure that anyone who gets a copy of any part of the software from
58
+ you also gets a copy of these terms. If you modify the software, you must
59
+ include in any modified copies of the software a prominent notice stating that
60
+ you have modified the software.
61
+
62
+ ### No Other Rights
63
+
64
+ These terms do not imply any licenses other than those expressly granted in
65
+ these terms.
66
+
67
+ ### Termination
68
+
69
+ If you use the software in violation of these terms, such use is not licensed,
70
+ and your license will automatically terminate. If the licensor provides you
71
+ with a notice of your violation, and you cease all violation of this license no
72
+ later than 30 days after you receive that notice, your license will be
73
+ reinstated retroactively. However, if you violate these terms after such
74
+ reinstatement, any additional violation of these terms will cause your license
75
+ to terminate automatically and permanently.
76
+
77
+ ### No Liability
78
+
79
+ As far as the law allows, the software comes as is, without any warranty or
80
+ condition, and the licensor will not be liable to you for any damages arising
81
+ out of these terms or the use or nature of the software, under any kind of
82
+ legal claim.
83
+
84
+ ### Definitions
85
+
86
+ The "licensor" is the entity offering these terms.
87
+
88
+ The "software" is the software the licensor makes available under these terms,
89
+ including any portion of it.
90
+
91
+ "You" refers to the individual or entity agreeing to these terms.
92
+
93
+ "Your company" is any legal entity, sole proprietorship, or other kind of
94
+ organization that you work for, plus all organizations that have control over,
95
+ are under the control of, or are under common control with that organization.
96
+ Control means ownership of substantially all the assets of an entity, or the
97
+ power to direct its management and policies by vote, contract, or otherwise.
98
+ Control can be direct or indirect.
99
+
100
+ "Your license" is the license granted to you for the software under these
101
+ terms.
102
+
103
+ "Use" means anything you do with the software requiring your license.
104
+
105
+ "Trademark" means trademarks, service marks, and similar rights.
@@ -0,0 +1,409 @@
1
+ Metadata-Version: 2.4
2
+ Name: dex-agents-langgraph
3
+ Version: 0.1.0
4
+ Summary: Durable Dex agent sessions for LangChain create_agent agents and LangGraph graphs
5
+ Author: Super Durable
6
+ License-Expression: LicenseRef-Sustainable-Use-1.0
7
+ License-File: LICENSE
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3 :: Only
11
+ Classifier: Typing :: Typed
12
+ Requires-Dist: dex-agents==0.1.0
13
+ Requires-Dist: dex-python-sdk==1.30.0
14
+ Requires-Dist: langchain>=1.4.7,<2
15
+ Requires-Dist: langchain-core>=1.7.3,<2
16
+ Requires-Dist: langgraph>=1.2.19,<2
17
+ Requires-Python: >=3.11
18
+ Project-URL: Homepage, https://github.com/superdurable/dex/tree/main/agents/python/langgraph
19
+ Project-URL: Repository, https://github.com/superdurable/dex
20
+ Project-URL: Changelog, https://github.com/superdurable/dex/blob/main/agents/CHANGELOG.md
21
+ Description-Content-Type: text/markdown
22
+
23
+ # dex-agents-langgraph
24
+
25
+ `dex-agents-langgraph` (import `dex_agents_langgraph`) runs agents built with
26
+ LangChain's `create_agent` and custom LangGraph graphs as durable Dex agent
27
+ sessions. It implements the runner contract of the
28
+ [`dex-agents` core](https://github.com/superdurable/dex/blob/main/agents/python/core/README.md); the plan is
29
+ [agent-framework-adapters.md](https://github.com/superdurable/dex/blob/main/docs/design/plan/agent-framework-adapters.md).
30
+
31
+ One Flow execution is one agent session; the Flow ID is the session ID. The
32
+ session runs in the core's **Step mode**. Every model call is one `RunModel`
33
+ Step: the graph makes one model call, and the tool node pauses it with
34
+ LangGraph's `interrupt()` before any tool runs. Every tool call, READ tools
35
+ included, then runs once in its own Dex Step, in parallel with the other calls
36
+ of that model message and after approval when its policy requires it. The next
37
+ `RunModel` resumes the graph from the stored LangGraph checkpoint with the
38
+ recorded results. A crash repeats at most the one model or tool call that was
39
+ running.
40
+
41
+ ## Installation
42
+
43
+ The package needs Python 3.11 or later. It requires `dex-agents` at the same
44
+ version, the released `dex-python-sdk==1.30.0`, `langchain>=1.4.7,<2`,
45
+ `langchain-core>=1.7.3,<2`, and `langgraph>=1.2.19,<2`:
46
+
47
+ ```bash
48
+ pip install dex-agents-langgraph
49
+ ```
50
+
51
+ Develop the package inside this repository with uv 0.12.2. There it resolves
52
+ `dex-agents` from `../core` by path through `[tool.uv.sources]`; the published
53
+ wheel requires `dex-agents==<same version>` from PyPI instead:
54
+
55
+ ```bash
56
+ cd agents/python/langgraph
57
+ uv sync --locked
58
+ DEXCLI=/path/to/dexcli ./ci.sh
59
+ ```
60
+
61
+ ## A `create_agent` agent
62
+
63
+ Add `DurableToolCallMiddleware` first in the agent's `middleware`, then
64
+ register the agent with `langchain_agent_session_flow`. This program serves the
65
+ agent, starts a session, approves the refund when the session asks, and prints
66
+ the conversation:
67
+
68
+ ```python
69
+ import asyncio
70
+ import json
71
+
72
+ from dex import (
73
+ AsyncClient,
74
+ AsyncWorker,
75
+ BlobCacheConfig,
76
+ ClientOptions,
77
+ LongPollTimeoutError,
78
+ Registry,
79
+ WorkerOptions,
80
+ open_blob_cache,
81
+ )
82
+ from dex_agents import AgentEventType, AgentSessionClient, ToolEffect, ToolPolicy
83
+ from dex_agents_langgraph import (
84
+ DurableToolCallMiddleware,
85
+ get_tool_call_idempotency_key,
86
+ langchain_agent_session_flow,
87
+ )
88
+ from langchain.agents import create_agent
89
+ from langchain.chat_models import init_chat_model
90
+ from langchain_core.runnables import RunnableConfig
91
+ from langchain_core.tools import ToolException, tool
92
+
93
+ ORDERS = {"7": "delivered", "8": "processing"}
94
+ REFUNDS_BY_KEY: dict[str, str] = {}
95
+
96
+
97
+ @tool
98
+ def search_orders(customer_id: str) -> str:
99
+ """List the customer's orders and their status."""
100
+ return json.dumps(ORDERS)
101
+
102
+
103
+ @tool
104
+ def refund_order(order_id: str, config: RunnableConfig) -> str:
105
+ """Refund one order."""
106
+ if order_id not in ORDERS:
107
+ raise ToolException(f"order {order_id} does not exist")
108
+ key = get_tool_call_idempotency_key(config) or order_id
109
+ REFUNDS_BY_KEY.setdefault(key, order_id)
110
+ return f"refunded order {order_id}"
111
+
112
+
113
+ def build_support_agent():
114
+ return create_agent(
115
+ init_chat_model("openai:gpt-5-mini"),
116
+ tools=[search_orders, refund_order],
117
+ system_prompt="You help customers with their orders.",
118
+ middleware=[DurableToolCallMiddleware()],
119
+ )
120
+
121
+
122
+ support_agent = langchain_agent_session_flow(
123
+ build_support_agent,
124
+ flow_type="SupportAgent",
125
+ tool_policies={
126
+ "search_orders": ToolPolicy(ToolEffect.READ),
127
+ "refund_order": ToolPolicy(ToolEffect.IDEMPOTENT_WRITE, requires_approval=True),
128
+ },
129
+ )
130
+
131
+
132
+ async def run_session(sessions: AgentSessionClient, session_id: str, text: str) -> None:
133
+ await sessions.start_session(session_id, text)
134
+ resume_token = ""
135
+ while True:
136
+ try:
137
+ message = await sessions.read_agent_event(session_id, resume_token)
138
+ except LongPollTimeoutError:
139
+ continue
140
+ resume_token = message.resume_token
141
+ event = message.value
142
+ payload = json.loads(event.payload)
143
+ if event.type == AgentEventType.TEXT_DELTA:
144
+ print(payload["text"], end="", flush=True)
145
+ elif event.type == AgentEventType.INTERRUPT_RAISED:
146
+ print(f"\napproving {payload['tool_name']}")
147
+ await sessions.resolve_interrupt(session_id, payload["interrupt_id"], approved=True)
148
+ elif event.type == AgentEventType.TURN_COMPLETED:
149
+ print()
150
+ return
151
+
152
+
153
+ async def main(address: str = "localhost:8801") -> None:
154
+ registry = Registry([support_agent], allow_async_handlers=True)
155
+ cache = open_blob_cache(BlobCacheConfig(".dex-cache", 64 * 1024 * 1024))
156
+ worker = AsyncWorker(registry, cache, WorkerOptions(server_address=address))
157
+ client = AsyncClient(registry, cache, ClientOptions(server_address=address))
158
+ serving = asyncio.create_task(worker.start())
159
+ try:
160
+ await worker.wait_pull_registered(timeout=30)
161
+ sessions = AgentSessionClient(client, support_agent)
162
+ await run_session(sessions, "support-session-1", "Please refund order 7")
163
+ snapshot = await sessions.get_agent_snapshot("support-session-1")
164
+ for stored in snapshot.messages:
165
+ print(stored.sequence, stored.role, stored.text or stored.tool_output)
166
+ finally:
167
+ await worker.stop()
168
+ await serving
169
+ await client.close()
170
+ cache.close()
171
+
172
+
173
+ if __name__ == "__main__":
174
+ asyncio.run(main())
175
+ ```
176
+
177
+ Run `dexcli dev`, set the model provider's key, and run the program. A real
178
+ application runs Workers and Clients in separate processes; build the Flow the
179
+ same way in each. `build_support_agent` is a factory, so only a Worker builds
180
+ the agent and needs the model key. An `async` factory also works, for example
181
+ to load MCP tools first.
182
+
183
+ `AgentSessionClient`, the session RPCs, events, and the snapshot are the core's;
184
+ see the [core README](https://github.com/superdurable/dex/blob/main/agents/python/core/README.md#running-an-agent).
185
+
186
+ ## A custom LangGraph graph
187
+
188
+ A graph built with `StateGraph` works when its state holds the conversation as
189
+ LangChain messages with the `add_messages` reducer, as `MessagesState` does.
190
+ Build its tool node with `durable_tool_node` instead of `ToolNode`, then
191
+ register it with `langgraph_agent_session_flow`:
192
+
193
+ ```python
194
+ from dex_agents_langgraph import durable_tool_node, langgraph_agent_session_flow
195
+ from langgraph.graph import START, MessagesState, StateGraph
196
+ from langgraph.prebuilt import tools_condition
197
+
198
+ tools = [search_orders, refund_order]
199
+ model = init_chat_model("openai:gpt-5-mini").bind_tools(tools)
200
+
201
+
202
+ async def call_model(state: MessagesState) -> dict:
203
+ return {"messages": [await model.ainvoke(state["messages"])]}
204
+
205
+
206
+ builder = StateGraph(MessagesState)
207
+ builder.add_node("agent", call_model)
208
+ builder.add_node("tools", durable_tool_node(tools))
209
+ builder.add_edge(START, "agent")
210
+ builder.add_conditional_edges("agent", tools_condition)
211
+ builder.add_edge("tools", "agent")
212
+
213
+ support_graph = langgraph_agent_session_flow(
214
+ builder.compile(),
215
+ flow_type="SupportGraph",
216
+ tool_policies={"search_orders": ToolPolicy(ToolEffect.READ)},
217
+ )
218
+ ```
219
+
220
+ Pass `messages_key` when the message list has another state key.
221
+ `durable_tool_node` defers all calls of one model message with one interrupt,
222
+ so they run as parallel Steps.
223
+
224
+ ## How a turn runs
225
+
226
+ ```text
227
+ StartAgentSession -> RunModel -> CallTool / CallSingleAttemptTool (one per call, in parallel,
228
+ AwaitToolApproval first when needed)
229
+ -> ResumeTurn -> RunModel -> ... -> RunModel (final answer)
230
+ ```
231
+
232
+ 1. The turn's first `RunModel` rebuilds every stored message with
233
+ `framework_message_to_langchain_message` and runs the graph with
234
+ `{"messages": history}` on a fresh, turn-local checkpointer. It replaces
235
+ any checkpointer compiled into the graph.
236
+ 2. The graph makes one model call. For every tool call, the middleware or
237
+ tool node resolves the tool's policy and raises `interrupt()` with a
238
+ `dex_deferred_tool_calls` value. No tool runs inside `RunModel`.
239
+ 3. The runner returns `ToolCallsRequested`, with one `DeferredToolCall` per
240
+ call in model order. Its run state is the last checkpoint stored in each
241
+ checkpoint namespace plus its pending writes, serialized with the
242
+ checkpointer's serializer, and the calls each interrupt deferred.
243
+ 4. The session runs each call in its own Step, in parallel, through
244
+ `execute_deferred_tool_call`, which validates the arguments against the
245
+ tool's schema and calls the tool's own `ainvoke` once.
246
+ 5. After `ResumeTurn` collects every result, the next `RunModel` restores the
247
+ checkpoint and invokes the graph with
248
+ `Command(resume={interrupt_id: recorded_results})`. The interrupted tool
249
+ tasks return the recorded results as their `ToolMessage` values, and the
250
+ graph makes its next model call. The runner fails the call if any recorded
251
+ result did not reach the graph.
252
+ 6. When the graph finishes, every message it added to the message state is
253
+ stored as one `AgentMessage`: `framework_item` holds
254
+ `message_to_dict(message)` as JSON, and the other fields are a display
255
+ projection.
256
+
257
+ During `RunModel` the runner streams visible model text as `text_delta` and
258
+ provider reasoning summaries as `reasoning_delta`, from LangGraph's
259
+ `messages` stream mode, including subgraphs. Group live text by `(turn_id,
260
+ step_execution_id, attempt)` as the core README describes, because a
261
+ `RunModel` retry streams its model call again. Usage counts each model call's
262
+ `usage_metadata`: `input_tokens`, `output_tokens`, `cache_read` input tokens,
263
+ and `reasoning` output tokens.
264
+
265
+ ## Effects and policies
266
+
267
+ | Effect | Step | Retry | Crash mid-call | Default approval |
268
+ | --- | --- | --- | --- | --- |
269
+ | `read` | `CallTool` | Up to 3 attempts; then `failed` | Runs again | No |
270
+ | `idempotent_write` | `CallTool` | Up to 3 attempts, key `<flowId>/<turnId>/<toolCallId>`; then `outcome_unknown` | Runs again with the same key | No |
271
+ | `non_idempotent_write` | `CallSingleAttemptTool` | One attempt; then `outcome_unknown` | Outcome unknown | Yes |
272
+ | `unclassified` | `CallSingleAttemptTool` | One attempt; then `outcome_unknown` | Outcome unknown | Yes |
273
+
274
+ A tool's effect comes from, in order: its `ToolPolicy` in `tool_policies`;
275
+ MCP annotations in the tool's `metadata` (`readOnlyHint: True` is `read`,
276
+ otherwise `idempotentHint: True` is `idempotent_write`), as tools converted
277
+ from MCP carry them; then `default_tool_effect`, which defaults to
278
+ `unclassified`. `tool_effect_from_metadata(tool)` shows what the annotations
279
+ give.
280
+
281
+ A tool call Step runs the tool outside the graph:
282
+
283
+ - The tool is the graph's tool of that name, from any `ToolNode` in the graph
284
+ or its subgraphs, or one passed in `tools=`. Pass tools that middleware adds
285
+ dynamically in `tools=`. Two tool nodes with different tools of one name
286
+ fail the runner's construction unless `tools=` names the one to run.
287
+ - A call to a tool the runner does not know, and that no policy names, gets
288
+ a `read` Step without approval, which records `failed`: "the agent has no
289
+ tool named ...".
290
+ - `get_tool_call_idempotency_key(config)` returns the session's idempotency
291
+ key, `"<flowId>/<turnId>/<toolCallId>"`, from the tool's `RunnableConfig`.
292
+ The key is the same on every attempt; pass it to systems that deduplicate
293
+ requests.
294
+ - Arguments that do not match the tool's `tool_call_schema`, a
295
+ `ToolException`, or an error `ToolMessage` record `failed`, and the model
296
+ sees `"The tool call failed: <message>"`. Any other exception, including a
297
+ `ValidationError` raised inside the tool body, is a Step failure: a `read`
298
+ or `idempotent_write` retries up to three attempts, a single-attempt call
299
+ does not, and then the session records `failed` for a read and
300
+ `outcome_unknown` for a write, because the write may have taken effect.
301
+ - The result reaches the model as text. Content that is not a string becomes
302
+ JSON text. A rejection or unknown outcome reaches the model as an error
303
+ `ToolMessage` with the core's text.
304
+ - A sub-agent that the tool invokes with `DurableToolCallMiddleware` runs its
305
+ own tools inline, as part of the tool's Step.
306
+
307
+ The runner also guards every tool. It blocks any tool that starts inside
308
+ `RunModel` and fails the call, with `last_turn_error` naming the tool. This
309
+ catches an agent built without `DurableToolCallMiddleware`, a plain
310
+ `ToolNode`, and a node that invokes a tool through its `invoke`, `ainvoke`,
311
+ `run`, or `arun` method. The guard sees tools through LangChain callbacks, so
312
+ code that calls a tool's underlying function directly, for example with
313
+ `loop.run_in_executor(None, tool.func, ...)`, bypasses it.
314
+
315
+ The policy of a call comes from the tool its Step runs. A tool in `tools=`
316
+ therefore decides the policy of a call to a node's tool of the same name.
317
+
318
+ ## Failed turns
319
+
320
+ When `RunModel` fails three times, the session records `last_turn_error`.
321
+ If the turn already has final tool call records, the session first stores a
322
+ turn-failure note built with this runner's `build_user_message`: a
323
+ `HumanMessage` listing each call and its result. The next turn replays it to
324
+ the model like any other user message, so the model does not repeat a write
325
+ that already ran.
326
+
327
+ ## Middleware order
328
+
329
+ Put `DurableToolCallMiddleware` first in `middleware`, so it is the outermost
330
+ tool wrapper. Middleware listed before it see every tool call. Middleware
331
+ listed after it never see a call inside a session, because the tool runs
332
+ outside the graph. Model middleware, such as dynamic tool selection or
333
+ summarization, keeps working; its model calls run inside `RunModel`.
334
+
335
+ LangChain's `HumanInTheLoopMiddleware` raises its own interrupts, which the
336
+ session cannot answer. Use `ToolPolicy(..., requires_approval=True)` and
337
+ `resolve_interrupt` instead.
338
+
339
+ ## Testing
340
+
341
+ `ci.sh` installs the locked dependencies, runs ruff, the format check, and
342
+ mypy, and runs the integration tests against private `dexcli dev --engine zd`
343
+ engines. It reads `DEXCLI`; `dex-server-zd` sits next to it.
344
+
345
+ The tests drive a real `create_agent` agent and a custom `StateGraph` with a
346
+ scripted chat model, so no model key is needed, and assert each turn's Step
347
+ history with `read_step_history`. For both they cover: a READ tool turn and
348
+ history replay; parallel tool Steps; approvals that survive a Worker restart,
349
+ run once, or wait while a read of the same message finishes; rejections; a
350
+ second model call that requests tools again; a Worker SIGKILL during
351
+ `RunModel`, which reruns only that model call, and during
352
+ `CallSingleAttemptTool`, which records `outcome_unknown`; a failed turn's
353
+ note; dynamic and unknown tools; a `ValidationError` inside a tool body; a
354
+ subgraph compiled with `checkpointer=False`; and a Worker whose clock runs
355
+ behind. With `create_agent` they also cover a second `resolve_interrupt`,
356
+ idempotency keys, the snapshot, and an agent without the middleware.
357
+ Construction tests cover two tool nodes with one tool name.
358
+
359
+ ## Limits
360
+
361
+ - A graph whose nodes make several model calls before they reach a tool
362
+ node, such as summarization middleware or a planner node, makes all of
363
+ them in one `RunModel`, and a retry repeats them all.
364
+ - A plain `ToolNode` behind `awrap_durable_tool_call` defers one call of a
365
+ task at a time; use `durable_tool_node` for parallel Steps. An outer
366
+ wrapper that awaits before calling `awrap_durable_tool_call` can reorder
367
+ the interrupts of one task, which fails the `RunModel` call.
368
+ - Provider-run tools, such as built-in tools passed as dicts, run inside the
369
+ model call, so they are at-least-once: a `RunModel` retry repeats them.
370
+ - History is append-only. Only messages whose IDs are new are stored, so a
371
+ graph that removes, summarizes, or edits earlier messages in its state does
372
+ not change the stored history; the next turn replays it unchanged.
373
+ - Only the message list survives between turns. Other state keys, such as
374
+ `structured_response`, start from their defaults on every turn. Within one
375
+ turn they survive, because they are in the checkpoint.
376
+ - `FrameworkRunState` holds the graph checkpoint, including the replayed
377
+ history, while tool calls run. Very long sessions store a large value.
378
+ - Tools run outside the graph, so they cannot use `ToolRuntime`,
379
+ `InjectedState`, or `InjectedStore`, and cannot return a graph `Command`.
380
+ `InjectedToolCallId` works. A response artifact (`content_and_artifact`) is
381
+ dropped; the model sees the content.
382
+ - A subgraph compiled with `checkpointer=False` restarts on resume, so the
383
+ runner fails the turn instead of letting it repeat its tool calls.
384
+ - The graph may raise no interrupts of its own, and static `interrupt_before`
385
+ or `interrupt_after` nodes fail the turn.
386
+ - State channels of type `DeltaChannel` are rejected, because the stored
387
+ checkpoint keeps only the last checkpoint of each namespace.
388
+ - The graph runs without a runtime `context`, so `Runtime.context` is
389
+ `None` in nodes and middleware.
390
+ - Tool call IDs from the model must be non-empty, contain no `/`, and be
391
+ unique within one turn.
392
+
393
+ ## Design decisions
394
+
395
+ - Every tool call, READ included, is deferred with `interrupt()`, so each
396
+ `RunModel` runs the graph up to one model call. There is no replay.
397
+ - `durable_tool_node` subclasses `ToolNode` and raises one interrupt for all
398
+ calls of a model message; `create_agent` already sends each call to its
399
+ own task.
400
+ - The run state is versioned JSON (`dex-agents-langgraph.checkpoint.v1`)
401
+ holding base64 values from the checkpoint serializer.
402
+ `checkpoint_serializer` accepts any LangGraph `SerializerProtocol`, such as
403
+ `EncryptedSerializer`; every Worker must use the same one.
404
+ - The run state keeps the checkpoint stored last in each namespace, not the
405
+ one with the largest ID, because checkpoint IDs follow each Worker's clock.
406
+ - A graph may be passed as a factory, sync or async, so model clients and
407
+ tool connections exist only in Workers.
408
+ - User messages get the ID `dex-user-<hex>`; a stored message without an ID
409
+ replays as `dex-message-<sequence>`.