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.
- dex_agents_langgraph-0.1.0/LICENSE +105 -0
- dex_agents_langgraph-0.1.0/PKG-INFO +409 -0
- dex_agents_langgraph-0.1.0/README.md +387 -0
- dex_agents_langgraph-0.1.0/pyproject.toml +90 -0
- dex_agents_langgraph-0.1.0/pyproject.toml.orig +69 -0
- dex_agents_langgraph-0.1.0/src/dex_agents_langgraph/__init__.py +58 -0
- dex_agents_langgraph-0.1.0/src/dex_agents_langgraph/_checkpoints.py +158 -0
- dex_agents_langgraph-0.1.0/src/dex_agents_langgraph/messages.py +142 -0
- dex_agents_langgraph-0.1.0/src/dex_agents_langgraph/py.typed +0 -0
- dex_agents_langgraph-0.1.0/src/dex_agents_langgraph/runner.py +587 -0
- dex_agents_langgraph-0.1.0/src/dex_agents_langgraph/session_flows.py +224 -0
- dex_agents_langgraph-0.1.0/src/dex_agents_langgraph/tool_deferral.py +469 -0
|
@@ -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>`.
|