dex-agents-langgraph
dex-agents-langgraph (import dex_agents_langgraph) runs agents built with
LangChain's create_agent and custom LangGraph graphs as durable Dex agent
sessions. It implements the runner contract of the
dex-agents core; the plan is
agent-framework-adapters.md.
One Flow execution is one agent session; the Flow ID is the session ID. The
session runs in the core's Step mode. Every model call is one RunModel
Step: the graph makes one model call, and the tool node pauses it with
LangGraph's interrupt() before any tool runs. Every tool call, READ tools
included, then runs once in its own Dex Step, in parallel with the other calls
of that model message and after approval when its policy requires it. The next
RunModel resumes the graph from the stored LangGraph checkpoint with the
recorded results. A crash repeats at most the one model or tool call that was
running.
Installation
The package needs Python 3.11 or later. It requires dex-agents at the same
version, the released dex-python-sdk==1.30.0, langchain>=1.4.7,<2,
langchain-core>=1.7.3,<2, and langgraph>=1.2.19,<2:
pip install dex-agents-langgraph
Develop the package inside this repository with uv 0.12.2. There it resolves
dex-agents from ../core by path through [tool.uv.sources]; the published
wheel requires dex-agents==<same version> from PyPI instead:
cd agents/python/langgraph
uv sync --locked
DEXCLI=/path/to/dexcli ./ci.sh
A create_agent agent
Add DurableToolCallMiddleware first in the agent's middleware, then
register the agent with langchain_agent_session_flow. This program serves the
agent, starts a session, approves the refund when the session asks, and prints
the conversation:
import asyncio
import json
from dex import (
AsyncClient,
AsyncWorker,
BlobCacheConfig,
ClientOptions,
LongPollTimeoutError,
Registry,
WorkerOptions,
open_blob_cache,
)
from dex_agents import AgentEventType, AgentSessionClient, ToolEffect, ToolPolicy
from dex_agents_langgraph import (
DurableToolCallMiddleware,
get_tool_call_idempotency_key,
langchain_agent_session_flow,
)
from langchain.agents import create_agent
from langchain.chat_models import init_chat_model
from langchain_core.runnables import RunnableConfig
from langchain_core.tools import ToolException, tool
ORDERS = {"7": "delivered", "8": "processing"}
REFUNDS_BY_KEY: dict[str, str] = {}
@tool
def search_orders(customer_id: str) -> str:
"""List the customer's orders and their status."""
return json.dumps(ORDERS)
@tool
def refund_order(order_id: str, config: RunnableConfig) -> str:
"""Refund one order."""
if order_id not in ORDERS:
raise ToolException(f"order {order_id} does not exist")
key = get_tool_call_idempotency_key(config) or order_id
REFUNDS_BY_KEY.setdefault(key, order_id)
return f"refunded order {order_id}"
def build_support_agent():
return create_agent(
init_chat_model("openai:gpt-5-mini"),
tools=[search_orders, refund_order],
system_prompt="You help customers with their orders.",
middleware=[DurableToolCallMiddleware()],
)
support_agent = langchain_agent_session_flow(
build_support_agent,
flow_type="SupportAgent",
tool_policies={
"search_orders": ToolPolicy(ToolEffect.READ),
"refund_order": ToolPolicy(ToolEffect.IDEMPOTENT_WRITE, requires_approval=True),
},
)
async def run_session(sessions: AgentSessionClient, session_id: str, text: str) -> None:
await sessions.start_session(session_id, text)
resume_token = ""
while True:
try:
message = await sessions.read_agent_event(session_id, resume_token)
except LongPollTimeoutError:
continue
resume_token = message.resume_token
event = message.value
payload = json.loads(event.payload)
if event.type == AgentEventType.TEXT_DELTA:
print(payload["text"], end="", flush=True)
elif event.type == AgentEventType.INTERRUPT_RAISED:
print(f"\napproving {payload['tool_name']}")
await sessions.resolve_interrupt(session_id, payload["interrupt_id"], approved=True)
elif event.type == AgentEventType.TURN_COMPLETED:
print()
return
async def main(address: str = "localhost:8801") -> None:
registry = Registry([support_agent], allow_async_handlers=True)
cache = open_blob_cache(BlobCacheConfig(".dex-cache", 64 * 1024 * 1024))
worker = AsyncWorker(registry, cache, WorkerOptions(server_address=address))
client = AsyncClient(registry, cache, ClientOptions(server_address=address))
serving = asyncio.create_task(worker.start())
try:
await worker.wait_pull_registered(timeout=30)
sessions = AgentSessionClient(client, support_agent)
await run_session(sessions, "support-session-1", "Please refund order 7")
snapshot = await sessions.get_agent_snapshot("support-session-1")
for stored in snapshot.messages:
print(stored.sequence, stored.role, stored.text or stored.tool_output)
finally:
await worker.stop()
await serving
await client.close()
cache.close()
if __name__ == "__main__":
asyncio.run(main())
Run dexcli dev, set the model provider's key, and run the program. A real
application runs Workers and Clients in separate processes; build the Flow the
same way in each. build_support_agent is a factory, so only a Worker builds
the agent and needs the model key. An async factory also works, for example
to load MCP tools first.
AgentSessionClient, the session RPCs, events, and the snapshot are the core's;
see the core README.
A custom LangGraph graph
A graph built with StateGraph works when its state holds the conversation as
LangChain messages with the add_messages reducer, as MessagesState does.
Build its tool node with durable_tool_node instead of ToolNode, then
register it with langgraph_agent_session_flow:
from dex_agents_langgraph import durable_tool_node, langgraph_agent_session_flow
from langgraph.graph import START, MessagesState, StateGraph
from langgraph.prebuilt import tools_condition
tools = [search_orders, refund_order]
model = init_chat_model("openai:gpt-5-mini").bind_tools(tools)
async def call_model(state: MessagesState) -> dict:
return {"messages": [await model.ainvoke(state["messages"])]}
builder = StateGraph(MessagesState)
builder.add_node("agent", call_model)
builder.add_node("tools", durable_tool_node(tools))
builder.add_edge(START, "agent")
builder.add_conditional_edges("agent", tools_condition)
builder.add_edge("tools", "agent")
support_graph = langgraph_agent_session_flow(
builder.compile(),
flow_type="SupportGraph",
tool_policies={"search_orders": ToolPolicy(ToolEffect.READ)},
)
Pass messages_key when the message list has another state key.
durable_tool_node defers all calls of one model message with one interrupt,
so they run as parallel Steps.
How a turn runs
StartAgentSession -> RunModel -> CallTool / CallSingleAttemptTool (one per call, in parallel,
AwaitToolApproval first when needed)
-> ResumeTurn -> RunModel -> ... -> RunModel (final answer)
- The turn's first
RunModelrebuilds every stored message withframework_message_to_langchain_messageand runs the graph with{"messages": history}on a fresh, turn-local checkpointer. It replaces any checkpointer compiled into the graph. - The graph makes one model call. For every tool call, the middleware or
tool node resolves the tool's policy and raises
interrupt()with adex_deferred_tool_callsvalue. No tool runs insideRunModel. - The runner returns
ToolCallsRequested, with oneDeferredToolCallper call in model order. Its run state is the last checkpoint stored in each checkpoint namespace plus its pending writes, serialized with the checkpointer's serializer, and the calls each interrupt deferred. - The session runs each call in its own Step, in parallel, through
execute_deferred_tool_call, which validates the arguments against the tool's schema and calls the tool's ownainvokeonce. - After
ResumeTurncollects every result, the nextRunModelrestores the checkpoint and invokes the graph withCommand(resume={interrupt_id: recorded_results}). The interrupted tool tasks return the recorded results as theirToolMessagevalues, and the graph makes its next model call. The runner fails the call if any recorded result did not reach the graph. - When the graph finishes, every message it added to the message state is
stored as one
AgentMessage:framework_itemholdsmessage_to_dict(message)as JSON, and the other fields are a display projection.
During RunModel the runner streams visible model text as text_delta and
provider reasoning summaries as reasoning_delta, from LangGraph's
messages stream mode, including subgraphs. Group live text by (turn_id, step_execution_id, attempt) as the core README describes, because a
RunModel retry streams its model call again. Usage counts each model call's
usage_metadata: input_tokens, output_tokens, cache_read input tokens,
and reasoning output tokens.
Effects and policies
| Effect | Step | Retry | Crash mid-call | Default approval |
|---|---|---|---|---|
read |
CallTool |
Up to 3 attempts; then failed |
Runs again | No |
idempotent_write |
CallTool |
Up to 3 attempts, key <flowId>/<turnId>/<toolCallId>; then outcome_unknown |
Runs again with the same key | No |
non_idempotent_write |
CallSingleAttemptTool |
One attempt; then outcome_unknown |
Outcome unknown | Yes |
unclassified |
CallSingleAttemptTool |
One attempt; then outcome_unknown |
Outcome unknown | Yes |
A tool's effect comes from, in order: its ToolPolicy in tool_policies;
MCP annotations in the tool's metadata (readOnlyHint: True is read,
otherwise idempotentHint: True is idempotent_write), as tools converted
from MCP carry them; then default_tool_effect, which defaults to
unclassified. tool_effect_from_metadata(tool) shows what the annotations
give.
A tool call Step runs the tool outside the graph:
- The tool is the graph's tool of that name, from any
ToolNodein the graph or its subgraphs, or one passed intools=. Pass tools that middleware adds dynamically intools=. Two tool nodes with different tools of one name fail the runner's construction unlesstools=names the one to run. - A call to a tool the runner does not know, and that no policy names, gets
a
readStep without approval, which recordsfailed: "the agent has no tool named ...". get_tool_call_idempotency_key(config)returns the session's idempotency key,"<flowId>/<turnId>/<toolCallId>", from the tool'sRunnableConfig. The key is the same on every attempt; pass it to systems that deduplicate requests.- Arguments that do not match the tool's
tool_call_schema, aToolException, or an errorToolMessagerecordfailed, and the model sees"The tool call failed: <message>". Any other exception, including aValidationErrorraised inside the tool body, is a Step failure: areadoridempotent_writeretries up to three attempts, a single-attempt call does not, and then the session recordsfailedfor a read andoutcome_unknownfor a write, because the write may have taken effect. - The result reaches the model as text. Content that is not a string becomes
JSON text. A rejection or unknown outcome reaches the model as an error
ToolMessagewith the core's text. - A sub-agent that the tool invokes with
DurableToolCallMiddlewareruns its own tools inline, as part of the tool's Step.
The runner also guards every tool. It blocks any tool that starts inside
RunModel and fails the call, with last_turn_error naming the tool. This
catches an agent built without DurableToolCallMiddleware, a plain
ToolNode, and a node that invokes a tool through its invoke, ainvoke,
run, or arun method. The guard sees tools through LangChain callbacks, so
code that calls a tool's underlying function directly, for example with
loop.run_in_executor(None, tool.func, ...), bypasses it.
The policy of a call comes from the tool its Step runs. A tool in tools=
therefore decides the policy of a call to a node's tool of the same name.
Failed turns
When RunModel fails three times, the session records last_turn_error.
If the turn already has final tool call records, the session first stores a
turn-failure note built with this runner's build_user_message: a
HumanMessage listing each call and its result. The next turn replays it to
the model like any other user message, so the model does not repeat a write
that already ran.
Middleware order
Put DurableToolCallMiddleware first in middleware, so it is the outermost
tool wrapper. Middleware listed before it see every tool call. Middleware
listed after it never see a call inside a session, because the tool runs
outside the graph. Model middleware, such as dynamic tool selection or
summarization, keeps working; its model calls run inside RunModel.
LangChain's HumanInTheLoopMiddleware raises its own interrupts, which the
session cannot answer. Use ToolPolicy(..., requires_approval=True) and
resolve_interrupt instead.
Testing
ci.sh installs the locked dependencies, runs ruff, the format check, and
mypy, and runs the integration tests against private dexcli dev --engine zd
engines. It reads DEXCLI; dex-server-zd sits next to it.
The tests drive a real create_agent agent and a custom StateGraph with a
scripted chat model, so no model key is needed, and assert each turn's Step
history with read_step_history. For both they cover: a READ tool turn and
history replay; parallel tool Steps; approvals that survive a Worker restart,
run once, or wait while a read of the same message finishes; rejections; a
second model call that requests tools again; a Worker SIGKILL during
RunModel, which reruns only that model call, and during
CallSingleAttemptTool, which records outcome_unknown; a failed turn's
note; dynamic and unknown tools; a ValidationError inside a tool body; a
subgraph compiled with checkpointer=False; and a Worker whose clock runs
behind. With create_agent they also cover a second resolve_interrupt,
idempotency keys, the snapshot, and an agent without the middleware.
Construction tests cover two tool nodes with one tool name.
Limits
- A graph whose nodes make several model calls before they reach a tool
node, such as summarization middleware or a planner node, makes all of
them in one
RunModel, and a retry repeats them all. - A plain
ToolNodebehindawrap_durable_tool_calldefers one call of a task at a time; usedurable_tool_nodefor parallel Steps. An outer wrapper that awaits before callingawrap_durable_tool_callcan reorder the interrupts of one task, which fails theRunModelcall. - Provider-run tools, such as built-in tools passed as dicts, run inside the
model call, so they are at-least-once: a
RunModelretry repeats them. - History is append-only. Only messages whose IDs are new are stored, so a graph that removes, summarizes, or edits earlier messages in its state does not change the stored history; the next turn replays it unchanged.
- Only the message list survives between turns. Other state keys, such as
structured_response, start from their defaults on every turn. Within one turn they survive, because they are in the checkpoint. FrameworkRunStateholds the graph checkpoint, including the replayed history, while tool calls run. Very long sessions store a large value.- Tools run outside the graph, so they cannot use
ToolRuntime,InjectedState, orInjectedStore, and cannot return a graphCommand.InjectedToolCallIdworks. A response artifact (content_and_artifact) is dropped; the model sees the content. - A subgraph compiled with
checkpointer=Falserestarts on resume, so the runner fails the turn instead of letting it repeat its tool calls. - The graph may raise no interrupts of its own, and static
interrupt_beforeorinterrupt_afternodes fail the turn. - State channels of type
DeltaChannelare rejected, because the stored checkpoint keeps only the last checkpoint of each namespace. - The graph runs without a runtime
context, soRuntime.contextisNonein nodes and middleware. - Tool call IDs from the model must be non-empty, contain no
/, and be unique within one turn.
Design decisions
- Every tool call, READ included, is deferred with
interrupt(), so eachRunModelruns the graph up to one model call. There is no replay. durable_tool_nodesubclassesToolNodeand raises one interrupt for all calls of a model message;create_agentalready sends each call to its own task.- The run state is versioned JSON (
dex-agents-langgraph.checkpoint.v1) holding base64 values from the checkpoint serializer.checkpoint_serializeraccepts any LangGraphSerializerProtocol, such asEncryptedSerializer; every Worker must use the same one. - The run state keeps the checkpoint stored last in each namespace, not the one with the largest ID, because checkpoint IDs follow each Worker's clock.
- A graph may be passed as a factory, sync or async, so model clients and tool connections exist only in Workers.
- User messages get the ID
dex-user-<hex>; a stored message without an ID replays asdex-message-<sequence>.
Metadata
Release files for dex-agents-langgraph 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| dex_agents_langgraph-0.1.0.tar.gz | 25.9 kB | Without provenance | Details for dex_agents_langgraph-0.1.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| dex_agents_langgraph-0.1.0-py3-none-any.whl | Python 3 | none | any | Details for dex_agents_langgraph-0.1.0-py3-none-any.whl |
Total release size: 56.8 kB
Release files / dex_agents_langgraph-0.1.0.tar.gz
| Download URL | dex_agents_langgraph-0.1.0.tar.gz |
|---|---|
| Size | 25.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
6a45b04ff3d3cd9d3a20573843b9a19754ac61b5b68139806182eee96dd44a90
|
|
BLAKE2b-256 checksum How to use checksums |
c2c3bdc795f5bd44be6d08585551ec62389d171a482e6d469515609556cd03d2
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.12.2 {"installer":{"name":"uv","version":"0.12.2","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / dex_agents_langgraph-0.1.0-py3-none-any.whl
| Download URL | dex_agents_langgraph-0.1.0-py3-none-any.whl |
|---|---|
| Size | 30.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
02849f583f97a149f88c1ef9897c8d90e79713f93668a9e93652097f654c41dd
|
|
BLAKE2b-256 checksum How to use checksums |
128549ec1b9f85023eb5fc0fc0e5877db989496993d452327ad371136bf16527
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.12.2 {"installer":{"name":"uv","version":"0.12.2","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Security for dex-agents-langgraph 0.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.