dex-agents-langgraph 0.1.0__py3-none-any.whl
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/__init__.py +58 -0
- dex_agents_langgraph/_checkpoints.py +158 -0
- dex_agents_langgraph/messages.py +142 -0
- dex_agents_langgraph/py.typed +0 -0
- dex_agents_langgraph/runner.py +587 -0
- dex_agents_langgraph/session_flows.py +224 -0
- dex_agents_langgraph/tool_deferral.py +469 -0
- dex_agents_langgraph-0.1.0.dist-info/METADATA +409 -0
- dex_agents_langgraph-0.1.0.dist-info/RECORD +11 -0
- dex_agents_langgraph-0.1.0.dist-info/WHEEL +4 -0
- dex_agents_langgraph-0.1.0.dist-info/licenses/LICENSE +105 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Copyright (c) 2026 Super Durable, Inc.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Sustainable Use License 1.0.
|
|
4
|
+
# You may not use this file except in compliance with the License.
|
|
5
|
+
# See the LICENSE file in the repository root.
|
|
6
|
+
#
|
|
7
|
+
# SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0
|
|
8
|
+
|
|
9
|
+
"""Durable Dex agent sessions for LangChain agents and LangGraph graphs.
|
|
10
|
+
|
|
11
|
+
Build an agent with ``langchain.agents.create_agent`` and
|
|
12
|
+
``DurableToolCallMiddleware``, or a custom ``StateGraph`` with
|
|
13
|
+
``durable_tool_node``, and register it with ``langchain_agent_session_flow``
|
|
14
|
+
or ``langgraph_agent_session_flow``. Each Flow execution is one agent session
|
|
15
|
+
in Step mode: every model call is one ``RunModel`` Step, and every tool call
|
|
16
|
+
runs in its own Step, in parallel, so a crash repeats at most the one call
|
|
17
|
+
that was running. See the package README for the full guide.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from dex_agents_langgraph.messages import (
|
|
21
|
+
framework_message_to_langchain_message,
|
|
22
|
+
framework_messages_to_langchain_messages,
|
|
23
|
+
langchain_message_to_framework_message,
|
|
24
|
+
)
|
|
25
|
+
from dex_agents_langgraph.runner import CompiledGraph, CompiledGraphSource, LangGraphAgentRunner
|
|
26
|
+
from dex_agents_langgraph.session_flows import (
|
|
27
|
+
langchain_agent_session_flow,
|
|
28
|
+
langgraph_agent_session_flow,
|
|
29
|
+
)
|
|
30
|
+
from dex_agents_langgraph.tool_deferral import (
|
|
31
|
+
TOOL_CALL_IDEMPOTENCY_KEY_CONFIGURABLE,
|
|
32
|
+
DurableToolCallMiddleware,
|
|
33
|
+
DurableToolNode,
|
|
34
|
+
ToolCallExecutor,
|
|
35
|
+
awrap_durable_tool_call,
|
|
36
|
+
durable_tool_node,
|
|
37
|
+
get_tool_call_idempotency_key,
|
|
38
|
+
tool_effect_from_metadata,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"TOOL_CALL_IDEMPOTENCY_KEY_CONFIGURABLE",
|
|
43
|
+
"CompiledGraph",
|
|
44
|
+
"CompiledGraphSource",
|
|
45
|
+
"DurableToolCallMiddleware",
|
|
46
|
+
"DurableToolNode",
|
|
47
|
+
"LangGraphAgentRunner",
|
|
48
|
+
"ToolCallExecutor",
|
|
49
|
+
"awrap_durable_tool_call",
|
|
50
|
+
"durable_tool_node",
|
|
51
|
+
"framework_message_to_langchain_message",
|
|
52
|
+
"framework_messages_to_langchain_messages",
|
|
53
|
+
"get_tool_call_idempotency_key",
|
|
54
|
+
"langchain_agent_session_flow",
|
|
55
|
+
"langchain_message_to_framework_message",
|
|
56
|
+
"langgraph_agent_session_flow",
|
|
57
|
+
"tool_effect_from_metadata",
|
|
58
|
+
]
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# Copyright (c) 2026 Super Durable, Inc.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Sustainable Use License 1.0.
|
|
4
|
+
# You may not use this file except in compliance with the License.
|
|
5
|
+
# See the LICENSE file in the repository root.
|
|
6
|
+
#
|
|
7
|
+
# SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0
|
|
8
|
+
|
|
9
|
+
"""The serialized LangGraph checkpoint that a turn stores while its tool calls run."""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import base64
|
|
14
|
+
import json
|
|
15
|
+
from collections.abc import Sequence
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from langchain_core.runnables import RunnableConfig
|
|
20
|
+
from langgraph.checkpoint.base import ChannelVersions, Checkpoint, CheckpointMetadata
|
|
21
|
+
from langgraph.checkpoint.memory import InMemorySaver
|
|
22
|
+
|
|
23
|
+
# Bump when the JSON shape changes; restore must keep reading every earlier version.
|
|
24
|
+
RUN_STATE_FORMAT = "dex-agents-langgraph.checkpoint.v1"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# Checkpoint IDs follow each Worker's clock, so the largest ID may be stale; track the last put.
|
|
28
|
+
class TurnCheckpointSaver(InMemorySaver):
|
|
29
|
+
def __init__(self, **saver_options: Any) -> None:
|
|
30
|
+
super().__init__(**saver_options)
|
|
31
|
+
self.latest_checkpoint_ids: dict[str, str] = {}
|
|
32
|
+
|
|
33
|
+
def put(
|
|
34
|
+
self,
|
|
35
|
+
config: RunnableConfig,
|
|
36
|
+
checkpoint: Checkpoint,
|
|
37
|
+
metadata: CheckpointMetadata,
|
|
38
|
+
new_versions: ChannelVersions,
|
|
39
|
+
) -> RunnableConfig:
|
|
40
|
+
saved_config = super().put(config, checkpoint, metadata, new_versions)
|
|
41
|
+
self.latest_checkpoint_ids[config["configurable"]["checkpoint_ns"]] = checkpoint["id"]
|
|
42
|
+
return saved_config
|
|
43
|
+
|
|
44
|
+
def latest_checkpoint_config(self, thread_id: str, namespace: str = "") -> RunnableConfig:
|
|
45
|
+
return {
|
|
46
|
+
"configurable": {
|
|
47
|
+
"thread_id": thread_id,
|
|
48
|
+
"checkpoint_ns": namespace,
|
|
49
|
+
"checkpoint_id": self.latest_checkpoint_ids[namespace],
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True, kw_only=True)
|
|
55
|
+
class PendingInterrupt:
|
|
56
|
+
interrupt_id: str
|
|
57
|
+
tool_call_ids: list[str]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True, kw_only=True)
|
|
61
|
+
class SuspendedGraphRunState:
|
|
62
|
+
thread_id: str
|
|
63
|
+
checkpoints: list[dict[str, Any]]
|
|
64
|
+
interrupts: list[PendingInterrupt]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def encode_suspended_graph_run_state(
|
|
68
|
+
saver: TurnCheckpointSaver,
|
|
69
|
+
thread_id: str,
|
|
70
|
+
interrupts: Sequence[PendingInterrupt],
|
|
71
|
+
) -> str:
|
|
72
|
+
serde = saver.serde
|
|
73
|
+
checkpoints: list[dict[str, Any]] = []
|
|
74
|
+
for namespace in sorted(saver.latest_checkpoint_ids):
|
|
75
|
+
checkpoint_tuple = await saver.aget_tuple(
|
|
76
|
+
saver.latest_checkpoint_config(thread_id, namespace)
|
|
77
|
+
)
|
|
78
|
+
if checkpoint_tuple is None:
|
|
79
|
+
raise RuntimeError(f"checkpoint namespace {namespace!r} lost its latest checkpoint")
|
|
80
|
+
checkpoints.append(
|
|
81
|
+
{
|
|
82
|
+
"checkpoint_ns": namespace,
|
|
83
|
+
"checkpoint": _encode_typed(serde.dumps_typed(checkpoint_tuple.checkpoint)),
|
|
84
|
+
"metadata": _encode_typed(serde.dumps_typed(checkpoint_tuple.metadata)),
|
|
85
|
+
"pending_writes": [
|
|
86
|
+
{
|
|
87
|
+
"task_id": task_id,
|
|
88
|
+
"channel": channel,
|
|
89
|
+
"value": _encode_typed(serde.dumps_typed(value)),
|
|
90
|
+
}
|
|
91
|
+
for task_id, channel, value in checkpoint_tuple.pending_writes or ()
|
|
92
|
+
],
|
|
93
|
+
}
|
|
94
|
+
)
|
|
95
|
+
return json.dumps(
|
|
96
|
+
{
|
|
97
|
+
"format": RUN_STATE_FORMAT,
|
|
98
|
+
"thread_id": thread_id,
|
|
99
|
+
"checkpoints": checkpoints,
|
|
100
|
+
"interrupts": [
|
|
101
|
+
{"interrupt_id": interrupt.interrupt_id, "tool_call_ids": interrupt.tool_call_ids}
|
|
102
|
+
for interrupt in interrupts
|
|
103
|
+
],
|
|
104
|
+
},
|
|
105
|
+
separators=(",", ":"),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def decode_suspended_graph_run_state(framework_run_state: str) -> SuspendedGraphRunState:
|
|
110
|
+
document = json.loads(framework_run_state)
|
|
111
|
+
if not isinstance(document, dict) or document.get("format") != RUN_STATE_FORMAT:
|
|
112
|
+
raise ValueError("the framework run state is not a LangGraph checkpoint of this adapter")
|
|
113
|
+
return SuspendedGraphRunState(
|
|
114
|
+
thread_id=str(document["thread_id"]),
|
|
115
|
+
checkpoints=list(document["checkpoints"]),
|
|
116
|
+
interrupts=[
|
|
117
|
+
PendingInterrupt(
|
|
118
|
+
interrupt_id=str(interrupt["interrupt_id"]),
|
|
119
|
+
tool_call_ids=[str(tool_call_id) for tool_call_id in interrupt["tool_call_ids"]],
|
|
120
|
+
)
|
|
121
|
+
for interrupt in document["interrupts"]
|
|
122
|
+
],
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
async def restore_suspended_graph_checkpoints(
|
|
127
|
+
saver: TurnCheckpointSaver,
|
|
128
|
+
run_state: SuspendedGraphRunState,
|
|
129
|
+
) -> None:
|
|
130
|
+
serde = saver.serde
|
|
131
|
+
for stored in run_state.checkpoints:
|
|
132
|
+
checkpoint = serde.loads_typed(_decode_typed(stored["checkpoint"]))
|
|
133
|
+
metadata = serde.loads_typed(_decode_typed(stored["metadata"]))
|
|
134
|
+
namespace_config: RunnableConfig = {
|
|
135
|
+
"configurable": {
|
|
136
|
+
"thread_id": run_state.thread_id,
|
|
137
|
+
"checkpoint_ns": stored["checkpoint_ns"],
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
saved_config = await saver.aput(
|
|
141
|
+
namespace_config, checkpoint, metadata, checkpoint["channel_versions"]
|
|
142
|
+
)
|
|
143
|
+
# put_writes indexes writes by position, so each task's writes go in one call.
|
|
144
|
+
writes_by_task_id: dict[str, list[tuple[str, Any]]] = {}
|
|
145
|
+
for write in stored["pending_writes"]:
|
|
146
|
+
writes_by_task_id.setdefault(write["task_id"], []).append(
|
|
147
|
+
(write["channel"], serde.loads_typed(_decode_typed(write["value"])))
|
|
148
|
+
)
|
|
149
|
+
for task_id, writes in writes_by_task_id.items():
|
|
150
|
+
await saver.aput_writes(saved_config, writes, task_id)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _encode_typed(typed: tuple[str, bytes]) -> dict[str, str]:
|
|
154
|
+
return {"type": typed[0], "data": base64.b64encode(typed[1]).decode("ascii")}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _decode_typed(encoded: dict[str, str]) -> tuple[str, bytes]:
|
|
158
|
+
return encoded["type"], base64.b64decode(encoded["data"])
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# Copyright (c) 2026 Super Durable, Inc.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Sustainable Use License 1.0.
|
|
4
|
+
# You may not use this file except in compliance with the License.
|
|
5
|
+
# See the LICENSE file in the repository root.
|
|
6
|
+
#
|
|
7
|
+
# SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0
|
|
8
|
+
|
|
9
|
+
"""Conversion between LangChain messages and stored agent messages."""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from collections.abc import Sequence
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from dex_agents import AgentMessage, FrameworkMessage, MessageRole
|
|
18
|
+
from langchain_core.messages import (
|
|
19
|
+
AIMessage,
|
|
20
|
+
BaseMessage,
|
|
21
|
+
ChatMessage,
|
|
22
|
+
HumanMessage,
|
|
23
|
+
ToolMessage,
|
|
24
|
+
message_to_dict,
|
|
25
|
+
messages_from_dict,
|
|
26
|
+
)
|
|
27
|
+
from pydantic_core import to_jsonable_python
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def langchain_message_to_framework_message(message: BaseMessage) -> FrameworkMessage:
|
|
31
|
+
"""Convert one LangChain message into the item an agent session stores.
|
|
32
|
+
|
|
33
|
+
The runner calls it for every message a turn adds to the graph's message
|
|
34
|
+
state. ``framework_item`` holds ``langchain_core.messages.message_to_dict``
|
|
35
|
+
as JSON text, which ``framework_message_to_langchain_message`` reads back
|
|
36
|
+
unchanged on the next turn. The other fields are a display projection:
|
|
37
|
+
|
|
38
|
+
* ``HumanMessage`` and a ``ChatMessage`` with role ``"user"`` become
|
|
39
|
+
``MessageRole.USER`` with ``text``.
|
|
40
|
+
* ``ToolMessage`` becomes ``MessageRole.TOOL`` with ``tool_call_id``,
|
|
41
|
+
``tool_name``, and ``tool_output``.
|
|
42
|
+
* Every other message, including ``AIMessage`` and ``SystemMessage``,
|
|
43
|
+
becomes ``MessageRole.ASSISTANT``. An ``AIMessage`` with exactly one tool
|
|
44
|
+
call also sets ``tool_call_id``, ``tool_name``, and ``tool_arguments``.
|
|
45
|
+
|
|
46
|
+
Values that JSON cannot represent directly, such as a ``datetime`` in
|
|
47
|
+
``response_metadata``, are stored in their JSON-compatible form, so they
|
|
48
|
+
come back as strings or plain containers.
|
|
49
|
+
|
|
50
|
+
Example:
|
|
51
|
+
>>> stored = langchain_message_to_framework_message(AIMessage("Done", id="ai-1"))
|
|
52
|
+
>>> stored.role, stored.text
|
|
53
|
+
(<MessageRole.ASSISTANT: 'assistant'>, 'Done')
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
message: A message from the graph's message state.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
The message with its JSON ``framework_item`` and display fields.
|
|
60
|
+
|
|
61
|
+
Raises:
|
|
62
|
+
TypeError: If a value in the message has no JSON-compatible form.
|
|
63
|
+
"""
|
|
64
|
+
framework_item = json.dumps(
|
|
65
|
+
message_to_dict(message), ensure_ascii=False, default=to_jsonable_python
|
|
66
|
+
)
|
|
67
|
+
if isinstance(message, HumanMessage) or (
|
|
68
|
+
isinstance(message, ChatMessage) and message.role == "user"
|
|
69
|
+
):
|
|
70
|
+
return FrameworkMessage(
|
|
71
|
+
role=MessageRole.USER, framework_item=framework_item, text=message.text
|
|
72
|
+
)
|
|
73
|
+
if isinstance(message, ToolMessage):
|
|
74
|
+
return FrameworkMessage(
|
|
75
|
+
role=MessageRole.TOOL,
|
|
76
|
+
framework_item=framework_item,
|
|
77
|
+
tool_call_id=message.tool_call_id,
|
|
78
|
+
tool_name=message.name,
|
|
79
|
+
tool_output=message.text,
|
|
80
|
+
)
|
|
81
|
+
text = message.text or None
|
|
82
|
+
if isinstance(message, AIMessage) and len(message.tool_calls) == 1:
|
|
83
|
+
tool_call = message.tool_calls[0]
|
|
84
|
+
return FrameworkMessage(
|
|
85
|
+
role=MessageRole.ASSISTANT,
|
|
86
|
+
framework_item=framework_item,
|
|
87
|
+
text=text,
|
|
88
|
+
tool_call_id=tool_call["id"],
|
|
89
|
+
tool_name=tool_call["name"],
|
|
90
|
+
tool_arguments=json.dumps(
|
|
91
|
+
tool_call["args"], ensure_ascii=False, default=to_jsonable_python
|
|
92
|
+
),
|
|
93
|
+
)
|
|
94
|
+
return FrameworkMessage(role=MessageRole.ASSISTANT, framework_item=framework_item, text=text)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def framework_message_to_langchain_message(message: AgentMessage) -> BaseMessage:
|
|
98
|
+
"""Rebuild the LangChain message that a stored agent message holds.
|
|
99
|
+
|
|
100
|
+
The runner calls it for every stored message to rebuild the graph's input.
|
|
101
|
+
A message stored without a LangChain message ID receives
|
|
102
|
+
``"dex-message-<sequence>"``, so later turns can tell stored messages from
|
|
103
|
+
new ones.
|
|
104
|
+
|
|
105
|
+
Example:
|
|
106
|
+
>>> history = [framework_message_to_langchain_message(m) for m in turn.messages]
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
message: A message from ``AgentTurnContext.messages``.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
The LangChain message that ``langchain_message_to_framework_message`` stored.
|
|
113
|
+
|
|
114
|
+
Raises:
|
|
115
|
+
ValueError: If ``framework_item`` is not a stored LangChain message.
|
|
116
|
+
"""
|
|
117
|
+
item: Any = json.loads(message.framework_item)
|
|
118
|
+
if not isinstance(item, dict) or "type" not in item or "data" not in item:
|
|
119
|
+
raise ValueError(
|
|
120
|
+
f"agent message {message.sequence} does not hold a stored LangChain message"
|
|
121
|
+
)
|
|
122
|
+
restored = messages_from_dict([item])[0]
|
|
123
|
+
if restored.id is None:
|
|
124
|
+
restored.id = f"dex-message-{message.sequence}"
|
|
125
|
+
return restored
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def framework_messages_to_langchain_messages(
|
|
129
|
+
messages: Sequence[AgentMessage],
|
|
130
|
+
) -> list[BaseMessage]:
|
|
131
|
+
"""Rebuild every stored message of a session, in sequence order.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
messages: ``AgentTurnContext.messages``.
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
LangChain messages in the same order.
|
|
138
|
+
|
|
139
|
+
Raises:
|
|
140
|
+
ValueError: If a ``framework_item`` is not a stored LangChain message.
|
|
141
|
+
"""
|
|
142
|
+
return [framework_message_to_langchain_message(message) for message in messages]
|
|
File without changes
|