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.
@@ -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