lingxigraph 2.0.1__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.
- lingxigraph/__init__.py +187 -0
- lingxigraph/__main__.py +6 -0
- lingxigraph/cache.py +84 -0
- lingxigraph/cache_redis.py +66 -0
- lingxigraph/channels.py +190 -0
- lingxigraph/checkpoint/__init__.py +115 -0
- lingxigraph/checkpoint/memory.py +130 -0
- lingxigraph/checkpoint/postgres.py +283 -0
- lingxigraph/checkpoint/sqlite.py +329 -0
- lingxigraph/cli.py +390 -0
- lingxigraph/constants.py +6 -0
- lingxigraph/errors.py +69 -0
- lingxigraph/events.py +57 -0
- lingxigraph/examples/__init__.py +1 -0
- lingxigraph/examples/multi_agent_graph.py +118 -0
- lingxigraph/examples/production_graph.py +36 -0
- lingxigraph/func.py +122 -0
- lingxigraph/graph/__init__.py +7 -0
- lingxigraph/graph/builder.py +317 -0
- lingxigraph/graph/executor.py +2403 -0
- lingxigraph/graph/structure.py +67 -0
- lingxigraph/integrations/__init__.py +37 -0
- lingxigraph/integrations/_http.py +52 -0
- lingxigraph/integrations/coze.py +850 -0
- lingxigraph/integrations/openai_compat.py +225 -0
- lingxigraph/messages.py +227 -0
- lingxigraph/models.py +33 -0
- lingxigraph/observability.py +127 -0
- lingxigraph/patterns/__init__.py +25 -0
- lingxigraph/patterns/multi_agent.py +410 -0
- lingxigraph/prebuilt.py +214 -0
- lingxigraph/protocols/__init__.py +12 -0
- lingxigraph/protocols/a2a.py +165 -0
- lingxigraph/protocols/mcp.py +169 -0
- lingxigraph/py.typed +1 -0
- lingxigraph/runtime.py +275 -0
- lingxigraph/scaffold.py +264 -0
- lingxigraph/schema.py +230 -0
- lingxigraph/sdk.py +522 -0
- lingxigraph/serialization.py +164 -0
- lingxigraph/server/__init__.py +14 -0
- lingxigraph/server/app.py +912 -0
- lingxigraph/server/eventbus.py +80 -0
- lingxigraph/server/migrations/0001_v1.sql +193 -0
- lingxigraph/server/models.py +209 -0
- lingxigraph/server/registry.py +110 -0
- lingxigraph/server/repository.py +1219 -0
- lingxigraph/server/security.py +125 -0
- lingxigraph/server/worker.py +303 -0
- lingxigraph/store/__init__.py +124 -0
- lingxigraph/store/memory.py +196 -0
- lingxigraph/store/postgres.py +242 -0
- lingxigraph/studio/index.html +113 -0
- lingxigraph/studio/studio.css +234 -0
- lingxigraph/studio/studio.js +821 -0
- lingxigraph/tools.py +331 -0
- lingxigraph/types.py +243 -0
- lingxigraph/version.py +5 -0
- lingxigraph-2.0.1.dist-info/METADATA +256 -0
- lingxigraph-2.0.1.dist-info/RECORD +64 -0
- lingxigraph-2.0.1.dist-info/WHEEL +5 -0
- lingxigraph-2.0.1.dist-info/entry_points.txt +2 -0
- lingxigraph-2.0.1.dist-info/licenses/LICENSE +21 -0
- lingxigraph-2.0.1.dist-info/top_level.txt +1 -0
lingxigraph/__init__.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Public API for the LingxiGraph runtime."""
|
|
2
|
+
|
|
3
|
+
from .cache import AsyncCache, BaseCache, InMemoryCache
|
|
4
|
+
from .channels import EphemeralValue, Topic
|
|
5
|
+
from .checkpoint import (
|
|
6
|
+
AsyncCheckpointer,
|
|
7
|
+
AsyncPostgresSaver,
|
|
8
|
+
Checkpoint,
|
|
9
|
+
Checkpointer,
|
|
10
|
+
CheckpointTuple,
|
|
11
|
+
InMemorySaver,
|
|
12
|
+
PendingWrite,
|
|
13
|
+
PostgresSaver,
|
|
14
|
+
SqliteSaver,
|
|
15
|
+
)
|
|
16
|
+
from .constants import END, START
|
|
17
|
+
from .errors import (
|
|
18
|
+
BudgetExceededError,
|
|
19
|
+
ConcurrentRunError,
|
|
20
|
+
EmptyInputError,
|
|
21
|
+
GraphCancelledError,
|
|
22
|
+
GraphInterrupt,
|
|
23
|
+
GraphRecursionError,
|
|
24
|
+
GraphTimeoutError,
|
|
25
|
+
GraphValidationError,
|
|
26
|
+
IdempotencyConflictError,
|
|
27
|
+
InvalidUpdateError,
|
|
28
|
+
LingxiGraphError,
|
|
29
|
+
PersistenceError,
|
|
30
|
+
)
|
|
31
|
+
from .events import Event, EventKind
|
|
32
|
+
from .func import entrypoint, task
|
|
33
|
+
from .graph import CompiledGraph, CompiledStateGraph, EdgeInfo, GraphInfo, NodeInfo, StateGraph
|
|
34
|
+
from .messages import (
|
|
35
|
+
REMOVE_ALL_MESSAGES,
|
|
36
|
+
AIMessage,
|
|
37
|
+
AIMessageChunk,
|
|
38
|
+
AnyMessage,
|
|
39
|
+
HumanMessage,
|
|
40
|
+
MessagesState,
|
|
41
|
+
RemoveMessage,
|
|
42
|
+
SystemMessage,
|
|
43
|
+
ToolCall,
|
|
44
|
+
ToolCallChunk,
|
|
45
|
+
ToolMessage,
|
|
46
|
+
add_messages,
|
|
47
|
+
merge_chunks,
|
|
48
|
+
)
|
|
49
|
+
from .models import ChatModel, StreamingChatModel
|
|
50
|
+
from .prebuilt import AgentState, create_agent, create_react_agent
|
|
51
|
+
from .runtime import (
|
|
52
|
+
CancellationToken,
|
|
53
|
+
ExecutionBudget,
|
|
54
|
+
Runtime,
|
|
55
|
+
get_config,
|
|
56
|
+
get_runtime,
|
|
57
|
+
get_store,
|
|
58
|
+
get_stream_writer,
|
|
59
|
+
)
|
|
60
|
+
from .serialization import JsonSerializer, SerializationError, Serializer
|
|
61
|
+
from .store import (
|
|
62
|
+
AsyncPostgresStore,
|
|
63
|
+
AsyncStore,
|
|
64
|
+
BaseStore,
|
|
65
|
+
Embedder,
|
|
66
|
+
InMemoryStore,
|
|
67
|
+
Item,
|
|
68
|
+
PostgresStore,
|
|
69
|
+
StoreOperation,
|
|
70
|
+
)
|
|
71
|
+
from .tools import ToolNode, ToolSpec, tool, tools_condition, validate_json_schema
|
|
72
|
+
from .types import (
|
|
73
|
+
CachePolicy,
|
|
74
|
+
Command,
|
|
75
|
+
CommandScope,
|
|
76
|
+
Durability,
|
|
77
|
+
Interrupt,
|
|
78
|
+
MultitaskStrategy,
|
|
79
|
+
RetryPolicy,
|
|
80
|
+
RunConfig,
|
|
81
|
+
RunStatus,
|
|
82
|
+
Send,
|
|
83
|
+
StateSnapshot,
|
|
84
|
+
StreamWriter,
|
|
85
|
+
SubgraphPersistence,
|
|
86
|
+
TaskSnapshot,
|
|
87
|
+
interrupt,
|
|
88
|
+
)
|
|
89
|
+
from .version import __version__
|
|
90
|
+
|
|
91
|
+
__all__ = [
|
|
92
|
+
"__version__",
|
|
93
|
+
"AsyncCache",
|
|
94
|
+
"AsyncCheckpointer",
|
|
95
|
+
"AsyncPostgresSaver",
|
|
96
|
+
"AsyncPostgresStore",
|
|
97
|
+
"AsyncStore",
|
|
98
|
+
"AIMessage",
|
|
99
|
+
"AIMessageChunk",
|
|
100
|
+
"AgentState",
|
|
101
|
+
"AnyMessage",
|
|
102
|
+
"BaseCache",
|
|
103
|
+
"BaseStore",
|
|
104
|
+
"BudgetExceededError",
|
|
105
|
+
"CachePolicy",
|
|
106
|
+
"CancellationToken",
|
|
107
|
+
"Checkpoint",
|
|
108
|
+
"Checkpointer",
|
|
109
|
+
"CheckpointTuple",
|
|
110
|
+
"ChatModel",
|
|
111
|
+
"Command",
|
|
112
|
+
"CommandScope",
|
|
113
|
+
"CompiledGraph",
|
|
114
|
+
"CompiledStateGraph",
|
|
115
|
+
"ConcurrentRunError",
|
|
116
|
+
"Durability",
|
|
117
|
+
"EdgeInfo",
|
|
118
|
+
"Embedder",
|
|
119
|
+
"ExecutionBudget",
|
|
120
|
+
"END",
|
|
121
|
+
"EmptyInputError",
|
|
122
|
+
"EphemeralValue",
|
|
123
|
+
"Event",
|
|
124
|
+
"EventKind",
|
|
125
|
+
"GraphCancelledError",
|
|
126
|
+
"GraphInterrupt",
|
|
127
|
+
"GraphRecursionError",
|
|
128
|
+
"GraphTimeoutError",
|
|
129
|
+
"GraphValidationError",
|
|
130
|
+
"GraphInfo",
|
|
131
|
+
"HumanMessage",
|
|
132
|
+
"IdempotencyConflictError",
|
|
133
|
+
"InMemoryCache",
|
|
134
|
+
"InMemorySaver",
|
|
135
|
+
"InMemoryStore",
|
|
136
|
+
"Interrupt",
|
|
137
|
+
"InvalidUpdateError",
|
|
138
|
+
"Item",
|
|
139
|
+
"JsonSerializer",
|
|
140
|
+
"LingxiGraphError",
|
|
141
|
+
"MultitaskStrategy",
|
|
142
|
+
"MessagesState",
|
|
143
|
+
"PendingWrite",
|
|
144
|
+
"NodeInfo",
|
|
145
|
+
"PersistenceError",
|
|
146
|
+
"PostgresSaver",
|
|
147
|
+
"PostgresStore",
|
|
148
|
+
"RetryPolicy",
|
|
149
|
+
"RunConfig",
|
|
150
|
+
"RunStatus",
|
|
151
|
+
"REMOVE_ALL_MESSAGES",
|
|
152
|
+
"RemoveMessage",
|
|
153
|
+
"Runtime",
|
|
154
|
+
"START",
|
|
155
|
+
"Send",
|
|
156
|
+
"SerializationError",
|
|
157
|
+
"Serializer",
|
|
158
|
+
"SqliteSaver",
|
|
159
|
+
"StateGraph",
|
|
160
|
+
"StateSnapshot",
|
|
161
|
+
"StreamWriter",
|
|
162
|
+
"StreamingChatModel",
|
|
163
|
+
"StoreOperation",
|
|
164
|
+
"SubgraphPersistence",
|
|
165
|
+
"SystemMessage",
|
|
166
|
+
"TaskSnapshot",
|
|
167
|
+
"ToolCall",
|
|
168
|
+
"ToolCallChunk",
|
|
169
|
+
"ToolMessage",
|
|
170
|
+
"ToolNode",
|
|
171
|
+
"ToolSpec",
|
|
172
|
+
"Topic",
|
|
173
|
+
"add_messages",
|
|
174
|
+
"create_agent",
|
|
175
|
+
"create_react_agent",
|
|
176
|
+
"entrypoint",
|
|
177
|
+
"get_config",
|
|
178
|
+
"get_runtime",
|
|
179
|
+
"get_store",
|
|
180
|
+
"get_stream_writer",
|
|
181
|
+
"interrupt",
|
|
182
|
+
"merge_chunks",
|
|
183
|
+
"tool",
|
|
184
|
+
"tools_condition",
|
|
185
|
+
"validate_json_schema",
|
|
186
|
+
"task",
|
|
187
|
+
]
|
lingxigraph/__main__.py
ADDED
lingxigraph/cache.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Provider-neutral cache protocol and in-memory implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import copy
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from threading import RLock
|
|
10
|
+
from typing import Any, Protocol, runtime_checkable
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class CacheEntry:
|
|
15
|
+
value: Any
|
|
16
|
+
expires_at: float | None = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@runtime_checkable
|
|
20
|
+
class BaseCache(Protocol):
|
|
21
|
+
def get(self, key: str) -> Any | None: ...
|
|
22
|
+
|
|
23
|
+
def set(self, key: str, value: Any, *, ttl: float | None = None) -> None: ...
|
|
24
|
+
|
|
25
|
+
def delete(self, key: str) -> None: ...
|
|
26
|
+
|
|
27
|
+
def clear(self, *, namespace: str | None = None) -> None: ...
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@runtime_checkable
|
|
31
|
+
class AsyncCache(Protocol):
|
|
32
|
+
async def aget(self, key: str) -> Any | None: ...
|
|
33
|
+
|
|
34
|
+
async def aset(self, key: str, value: Any, *, ttl: float | None = None) -> None: ...
|
|
35
|
+
|
|
36
|
+
async def adelete(self, key: str) -> None: ...
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class InMemoryCache:
|
|
40
|
+
"""Thread-safe TTL cache intended for tests and embedded deployments."""
|
|
41
|
+
|
|
42
|
+
def __init__(self) -> None:
|
|
43
|
+
self._items: dict[str, CacheEntry] = {}
|
|
44
|
+
self._lock = RLock()
|
|
45
|
+
|
|
46
|
+
def get(self, key: str) -> Any | None:
|
|
47
|
+
with self._lock:
|
|
48
|
+
entry = self._items.get(key)
|
|
49
|
+
if entry is None:
|
|
50
|
+
return None
|
|
51
|
+
if entry.expires_at is not None and entry.expires_at <= time.monotonic():
|
|
52
|
+
self._items.pop(key, None)
|
|
53
|
+
return None
|
|
54
|
+
return copy.deepcopy(entry.value)
|
|
55
|
+
|
|
56
|
+
def set(self, key: str, value: Any, *, ttl: float | None = None) -> None:
|
|
57
|
+
expires_at = time.monotonic() + ttl if ttl is not None else None
|
|
58
|
+
with self._lock:
|
|
59
|
+
self._items[key] = CacheEntry(copy.deepcopy(value), expires_at)
|
|
60
|
+
|
|
61
|
+
def delete(self, key: str) -> None:
|
|
62
|
+
with self._lock:
|
|
63
|
+
self._items.pop(key, None)
|
|
64
|
+
|
|
65
|
+
def clear(self, *, namespace: str | None = None) -> None:
|
|
66
|
+
with self._lock:
|
|
67
|
+
if namespace is None:
|
|
68
|
+
self._items.clear()
|
|
69
|
+
return
|
|
70
|
+
prefix = f"{namespace}:"
|
|
71
|
+
for key in [item for item in self._items if item.startswith(prefix)]:
|
|
72
|
+
self._items.pop(key, None)
|
|
73
|
+
|
|
74
|
+
async def aget(self, key: str) -> Any | None:
|
|
75
|
+
return await asyncio.to_thread(self.get, key)
|
|
76
|
+
|
|
77
|
+
async def aset(self, key: str, value: Any, *, ttl: float | None = None) -> None:
|
|
78
|
+
await asyncio.to_thread(self.set, key, value, ttl=ttl)
|
|
79
|
+
|
|
80
|
+
async def adelete(self, key: str) -> None:
|
|
81
|
+
await asyncio.to_thread(self.delete, key)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
__all__ = ["AsyncCache", "BaseCache", "CacheEntry", "InMemoryCache"]
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Optional Redis-backed node cache."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .serialization import JsonSerializer
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RedisCache:
|
|
12
|
+
"""Async-first Redis cache with strict JSON payloads and namespace clearing."""
|
|
13
|
+
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
url: str = "redis://localhost:6379/0",
|
|
17
|
+
*,
|
|
18
|
+
prefix: str = "lingxigraph:cache",
|
|
19
|
+
serializer: JsonSerializer | None = None,
|
|
20
|
+
) -> None:
|
|
21
|
+
try:
|
|
22
|
+
from redis.asyncio import Redis
|
|
23
|
+
except ImportError as exc: # pragma: no cover - optional dependency
|
|
24
|
+
raise RuntimeError("install lingxigraph[redis] to use RedisCache") from exc
|
|
25
|
+
self._redis = Redis.from_url(url)
|
|
26
|
+
self._prefix = prefix.rstrip(":")
|
|
27
|
+
self._serializer = serializer or JsonSerializer()
|
|
28
|
+
|
|
29
|
+
def _key(self, key: str) -> str:
|
|
30
|
+
return f"{self._prefix}:{key}"
|
|
31
|
+
|
|
32
|
+
async def aget(self, key: str) -> Any | None:
|
|
33
|
+
payload = await self._redis.get(self._key(key))
|
|
34
|
+
return None if payload is None else self._serializer.loads(payload)
|
|
35
|
+
|
|
36
|
+
async def aset(self, key: str, value: Any, *, ttl: float | None = None) -> None:
|
|
37
|
+
milliseconds = max(1, int(ttl * 1000)) if ttl is not None else None
|
|
38
|
+
await self._redis.set(
|
|
39
|
+
self._key(key), self._serializer.dumps(value), px=milliseconds
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
async def adelete(self, key: str) -> None:
|
|
43
|
+
await self._redis.delete(self._key(key))
|
|
44
|
+
|
|
45
|
+
async def aclear(self, *, namespace: str | None = None) -> None:
|
|
46
|
+
pattern = self._key(f"{namespace}:*" if namespace else "*")
|
|
47
|
+
async for key in self._redis.scan_iter(match=pattern, count=500):
|
|
48
|
+
await self._redis.delete(key)
|
|
49
|
+
|
|
50
|
+
def get(self, key: str) -> Any | None:
|
|
51
|
+
return asyncio.run(self.aget(key))
|
|
52
|
+
|
|
53
|
+
def set(self, key: str, value: Any, *, ttl: float | None = None) -> None:
|
|
54
|
+
asyncio.run(self.aset(key, value, ttl=ttl))
|
|
55
|
+
|
|
56
|
+
def delete(self, key: str) -> None:
|
|
57
|
+
asyncio.run(self.adelete(key))
|
|
58
|
+
|
|
59
|
+
def clear(self, *, namespace: str | None = None) -> None:
|
|
60
|
+
asyncio.run(self.aclear(namespace=namespace))
|
|
61
|
+
|
|
62
|
+
async def close(self) -> None:
|
|
63
|
+
await self._redis.aclose()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
__all__ = ["RedisCache"]
|
lingxigraph/channels.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""State channels and reducer discovery for ``TypedDict`` schemas."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
from collections.abc import Callable, Mapping
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Annotated, Any, get_args, get_origin, get_type_hints
|
|
9
|
+
|
|
10
|
+
from .errors import InvalidUpdateError
|
|
11
|
+
|
|
12
|
+
Reducer = Callable[[Any, Any], Any]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class ReplaceValue:
|
|
17
|
+
"""Internal write wrapper that overwrites a channel instead of reducing.
|
|
18
|
+
|
|
19
|
+
Subgraph nodes return their final channel values wrapped in this marker:
|
|
20
|
+
the child already merged the parent's seed value, so folding the result
|
|
21
|
+
through the reducer again would double-count it.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
value: Any
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class LastValue:
|
|
29
|
+
"""A channel that accepts at most one write per superstep."""
|
|
30
|
+
|
|
31
|
+
value_type: Any = Any
|
|
32
|
+
|
|
33
|
+
def merge(self, current: Any, writes: list[Any], *, key: str) -> Any:
|
|
34
|
+
if len(writes) > 1:
|
|
35
|
+
raise InvalidUpdateError(
|
|
36
|
+
f"state key {key!r} received {len(writes)} writes in one superstep; "
|
|
37
|
+
"declare an Annotated reducer to merge concurrent writes"
|
|
38
|
+
)
|
|
39
|
+
if not writes:
|
|
40
|
+
return current
|
|
41
|
+
write = writes[0]
|
|
42
|
+
if isinstance(write, ReplaceValue):
|
|
43
|
+
write = write.value
|
|
44
|
+
return copy.deepcopy(write)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True, slots=True)
|
|
48
|
+
class BinaryOperatorAggregate:
|
|
49
|
+
"""A channel that folds writes through a binary reducer."""
|
|
50
|
+
|
|
51
|
+
value_type: Any
|
|
52
|
+
operator: Reducer
|
|
53
|
+
|
|
54
|
+
def merge(self, current: Any, writes: list[Any], *, key: str) -> Any:
|
|
55
|
+
del key
|
|
56
|
+
if not writes:
|
|
57
|
+
return current
|
|
58
|
+
result = _MISSING if current is _MISSING else copy.deepcopy(current)
|
|
59
|
+
for value in writes:
|
|
60
|
+
if isinstance(value, ReplaceValue):
|
|
61
|
+
result = copy.deepcopy(value.value)
|
|
62
|
+
elif result is _MISSING:
|
|
63
|
+
result = copy.deepcopy(value)
|
|
64
|
+
else:
|
|
65
|
+
result = self.operator(result, copy.deepcopy(value))
|
|
66
|
+
return result
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True, slots=True)
|
|
70
|
+
class Topic:
|
|
71
|
+
"""A multi-value channel that optionally accumulates across supersteps."""
|
|
72
|
+
|
|
73
|
+
value_type: Any = Any
|
|
74
|
+
accumulate: bool = False
|
|
75
|
+
|
|
76
|
+
def merge(self, current: Any, writes: list[Any], *, key: str) -> Any:
|
|
77
|
+
del key
|
|
78
|
+
values: list[Any] = []
|
|
79
|
+
if self.accumulate and current is not _MISSING:
|
|
80
|
+
values.extend(copy.deepcopy(list(current)))
|
|
81
|
+
for write in writes:
|
|
82
|
+
if isinstance(write, ReplaceValue):
|
|
83
|
+
values = copy.deepcopy(list(write.value))
|
|
84
|
+
elif isinstance(write, (list, tuple)):
|
|
85
|
+
values.extend(copy.deepcopy(list(write)))
|
|
86
|
+
else:
|
|
87
|
+
values.append(copy.deepcopy(write))
|
|
88
|
+
return values
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True, slots=True)
|
|
92
|
+
class EphemeralValue:
|
|
93
|
+
"""A last-value channel cleared when a superstep does not write it."""
|
|
94
|
+
|
|
95
|
+
value_type: Any = Any
|
|
96
|
+
|
|
97
|
+
def merge(self, current: Any, writes: list[Any], *, key: str) -> Any:
|
|
98
|
+
if len(writes) > 1:
|
|
99
|
+
raise InvalidUpdateError(
|
|
100
|
+
f"ephemeral state key {key!r} received {len(writes)} writes in one superstep"
|
|
101
|
+
)
|
|
102
|
+
if not writes:
|
|
103
|
+
return _CLEAR
|
|
104
|
+
write = writes[0]
|
|
105
|
+
return copy.deepcopy(write.value if isinstance(write, ReplaceValue) else write)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
Channel = LastValue | BinaryOperatorAggregate | Topic | EphemeralValue
|
|
109
|
+
_MISSING = object()
|
|
110
|
+
_CLEAR = object()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def extract_channels(schema: type) -> dict[str, Channel]:
|
|
114
|
+
"""Build channels from a ``TypedDict``-style annotated schema."""
|
|
115
|
+
|
|
116
|
+
model_fields = getattr(schema, "model_fields", None)
|
|
117
|
+
if isinstance(model_fields, Mapping):
|
|
118
|
+
hints = {
|
|
119
|
+
name: getattr(field, "annotation", Any)
|
|
120
|
+
for name, field in model_fields.items()
|
|
121
|
+
}
|
|
122
|
+
else:
|
|
123
|
+
try:
|
|
124
|
+
hints = get_type_hints(schema, include_extras=True)
|
|
125
|
+
except (NameError, TypeError) as exc:
|
|
126
|
+
raise InvalidUpdateError(f"cannot inspect state schema {schema!r}: {exc}") from exc
|
|
127
|
+
if not hints:
|
|
128
|
+
raise InvalidUpdateError("state schema must declare at least one annotated key")
|
|
129
|
+
|
|
130
|
+
channels: dict[str, Channel] = {}
|
|
131
|
+
for key, hint in hints.items():
|
|
132
|
+
if get_origin(hint) is Annotated:
|
|
133
|
+
args = get_args(hint)
|
|
134
|
+
value_type, metadata = args[0], args[1:]
|
|
135
|
+
channel = next((item for item in metadata if isinstance(item, (LastValue, BinaryOperatorAggregate, Topic, EphemeralValue))), None)
|
|
136
|
+
if channel is not None:
|
|
137
|
+
channels[key] = channel
|
|
138
|
+
continue
|
|
139
|
+
reducer = next((item for item in metadata if callable(item)), None)
|
|
140
|
+
channels[key] = (
|
|
141
|
+
BinaryOperatorAggregate(value_type, reducer)
|
|
142
|
+
if reducer is not None
|
|
143
|
+
else LastValue(value_type)
|
|
144
|
+
)
|
|
145
|
+
else:
|
|
146
|
+
channels[key] = LastValue(hint)
|
|
147
|
+
return channels
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def merge_updates(
|
|
151
|
+
state: Mapping[str, Any],
|
|
152
|
+
updates: list[tuple[str, Mapping[str, Any]]],
|
|
153
|
+
channels: Mapping[str, Channel],
|
|
154
|
+
) -> dict[str, Any]:
|
|
155
|
+
"""Merge node updates deterministically in the supplied task order."""
|
|
156
|
+
|
|
157
|
+
writes: dict[str, list[Any]] = {key: [] for key in channels}
|
|
158
|
+
for node, update in updates:
|
|
159
|
+
if not isinstance(update, Mapping):
|
|
160
|
+
raise InvalidUpdateError(
|
|
161
|
+
f"node {node!r} returned {type(update).__name__}; expected dict or Command"
|
|
162
|
+
)
|
|
163
|
+
unknown = set(update) - set(channels)
|
|
164
|
+
if unknown:
|
|
165
|
+
names = ", ".join(sorted(unknown))
|
|
166
|
+
raise InvalidUpdateError(f"node {node!r} wrote unknown state key(s): {names}")
|
|
167
|
+
for key, value in update.items():
|
|
168
|
+
writes[key].append(value)
|
|
169
|
+
|
|
170
|
+
merged = copy.deepcopy(dict(state))
|
|
171
|
+
for key, channel in channels.items():
|
|
172
|
+
current = merged.get(key, _MISSING)
|
|
173
|
+
value = channel.merge(current, writes[key], key=key)
|
|
174
|
+
if value is _CLEAR:
|
|
175
|
+
merged.pop(key, None)
|
|
176
|
+
elif value is not _MISSING:
|
|
177
|
+
merged[key] = value
|
|
178
|
+
return merged
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
__all__ = [
|
|
182
|
+
"BinaryOperatorAggregate",
|
|
183
|
+
"Channel",
|
|
184
|
+
"LastValue",
|
|
185
|
+
"EphemeralValue",
|
|
186
|
+
"ReplaceValue",
|
|
187
|
+
"Topic",
|
|
188
|
+
"extract_channels",
|
|
189
|
+
"merge_updates",
|
|
190
|
+
]
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Checkpoint protocol and persisted value types."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import AsyncIterator, Iterable, Mapping
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any, Protocol, runtime_checkable
|
|
8
|
+
|
|
9
|
+
from ..types import Interrupt, Send, TaskSnapshot
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, slots=True)
|
|
13
|
+
class PendingWrite:
|
|
14
|
+
"""One durable task result written before its superstep commits."""
|
|
15
|
+
|
|
16
|
+
checkpoint_id: str
|
|
17
|
+
task_id: str
|
|
18
|
+
index: int
|
|
19
|
+
values: Mapping[str, Any]
|
|
20
|
+
task_path: tuple[str, ...] = ()
|
|
21
|
+
goto: tuple[str | Send, ...] = ()
|
|
22
|
+
error: str | None = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class Checkpoint:
|
|
27
|
+
id: str
|
|
28
|
+
ts: str
|
|
29
|
+
step: int
|
|
30
|
+
channel_values: Mapping[str, Any]
|
|
31
|
+
next: tuple[str, ...]
|
|
32
|
+
pending_sends: tuple[Send, ...] = ()
|
|
33
|
+
pending_interrupts: tuple[Interrupt, ...] = ()
|
|
34
|
+
parent_id: str | None = None
|
|
35
|
+
namespace: tuple[str, ...] = ()
|
|
36
|
+
run_id: str | None = None
|
|
37
|
+
channel_versions: Mapping[str, int] = field(default_factory=dict)
|
|
38
|
+
tasks: tuple[TaskSnapshot, ...] = ()
|
|
39
|
+
schema_version: int = 2
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True, slots=True)
|
|
43
|
+
class CheckpointTuple:
|
|
44
|
+
config: Mapping[str, Any]
|
|
45
|
+
checkpoint: Checkpoint
|
|
46
|
+
metadata: Mapping[str, Any] = field(default_factory=dict)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@runtime_checkable
|
|
50
|
+
class Checkpointer(Protocol):
|
|
51
|
+
def put(
|
|
52
|
+
self,
|
|
53
|
+
config: Mapping[str, Any],
|
|
54
|
+
checkpoint: Checkpoint,
|
|
55
|
+
metadata: Mapping[str, Any],
|
|
56
|
+
) -> Mapping[str, Any]: ...
|
|
57
|
+
|
|
58
|
+
def get_tuple(self, config: Mapping[str, Any]) -> CheckpointTuple | None: ...
|
|
59
|
+
|
|
60
|
+
def list(self, config: Mapping[str, Any]) -> Iterable[CheckpointTuple]: ...
|
|
61
|
+
|
|
62
|
+
def put_writes(
|
|
63
|
+
self,
|
|
64
|
+
config: Mapping[str, Any],
|
|
65
|
+
checkpoint_id: str,
|
|
66
|
+
writes: Iterable[PendingWrite],
|
|
67
|
+
) -> None: ...
|
|
68
|
+
|
|
69
|
+
def get_writes(
|
|
70
|
+
self, config: Mapping[str, Any], checkpoint_id: str
|
|
71
|
+
) -> Iterable[PendingWrite]: ...
|
|
72
|
+
|
|
73
|
+
def delete_thread(self, config: Mapping[str, Any]) -> None: ...
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@runtime_checkable
|
|
77
|
+
class AsyncCheckpointer(Protocol):
|
|
78
|
+
async def aput(
|
|
79
|
+
self,
|
|
80
|
+
config: Mapping[str, Any],
|
|
81
|
+
checkpoint: Checkpoint,
|
|
82
|
+
metadata: Mapping[str, Any],
|
|
83
|
+
) -> Mapping[str, Any]: ...
|
|
84
|
+
|
|
85
|
+
async def aget_tuple(self, config: Mapping[str, Any]) -> CheckpointTuple | None: ...
|
|
86
|
+
|
|
87
|
+
def alist(self, config: Mapping[str, Any]) -> AsyncIterator[CheckpointTuple]: ...
|
|
88
|
+
|
|
89
|
+
async def aput_writes(
|
|
90
|
+
self,
|
|
91
|
+
config: Mapping[str, Any],
|
|
92
|
+
checkpoint_id: str,
|
|
93
|
+
writes: Iterable[PendingWrite],
|
|
94
|
+
) -> None: ...
|
|
95
|
+
|
|
96
|
+
async def aget_writes(
|
|
97
|
+
self, config: Mapping[str, Any], checkpoint_id: str
|
|
98
|
+
) -> Iterable[PendingWrite]: ...
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
from .memory import InMemorySaver
|
|
102
|
+
from .postgres import AsyncPostgresSaver, PostgresSaver
|
|
103
|
+
from .sqlite import SqliteSaver
|
|
104
|
+
|
|
105
|
+
__all__ = [
|
|
106
|
+
"AsyncCheckpointer",
|
|
107
|
+
"Checkpoint",
|
|
108
|
+
"Checkpointer",
|
|
109
|
+
"CheckpointTuple",
|
|
110
|
+
"InMemorySaver",
|
|
111
|
+
"PendingWrite",
|
|
112
|
+
"PostgresSaver",
|
|
113
|
+
"AsyncPostgresSaver",
|
|
114
|
+
"SqliteSaver",
|
|
115
|
+
]
|