pi-durable-python 1.3.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.
- pi_durable/__init__.py +320 -0
- pi_durable/context.py +328 -0
- pi_durable/delta.py +269 -0
- pi_durable/documents.py +366 -0
- pi_durable/entries.py +66 -0
- pi_durable/env/__init__.py +408 -0
- pi_durable/env/decode.py +48 -0
- pi_durable/env/line_scan.py +190 -0
- pi_durable/env/posix.py +1547 -0
- pi_durable/errors.py +89 -0
- pi_durable/harness/__init__.py +1 -0
- pi_durable/harness/agent.py +344 -0
- pi_durable/harness/compaction.py +673 -0
- pi_durable/harness/context.py +515 -0
- pi_durable/harness/define.py +111 -0
- pi_durable/harness/events.py +766 -0
- pi_durable/harness/generation.py +1079 -0
- pi_durable/harness/harness.py +570 -0
- pi_durable/harness/inbox.py +255 -0
- pi_durable/harness/live.py +374 -0
- pi_durable/harness/output.py +467 -0
- pi_durable/harness/prompt.py +288 -0
- pi_durable/harness/provider.py +78 -0
- pi_durable/harness/registry.py +136 -0
- pi_durable/harness/scheduler.py +1977 -0
- pi_durable/harness/submissions.py +357 -0
- pi_durable/harness/task_graph.py +334 -0
- pi_durable/harness/tool.py +1365 -0
- pi_durable/harness/types.py +772 -0
- pi_durable/harness/usage.py +88 -0
- pi_durable/harness/util.py +204 -0
- pi_durable/harness/validation.py +460 -0
- pi_durable/harness/view.py +324 -0
- pi_durable/ids.py +63 -0
- pi_durable/json.py +186 -0
- pi_durable/providers/__init__.py +362 -0
- pi_durable/providers/anthropic.py +603 -0
- pi_durable/providers/base.py +97 -0
- pi_durable/providers/json_parse.py +117 -0
- pi_durable/providers/openai.py +574 -0
- pi_durable/providers/request.py +35 -0
- pi_durable/providers/retry.py +127 -0
- pi_durable/providers/stream.py +183 -0
- pi_durable/providers/transcript.py +224 -0
- pi_durable/providers/usage.py +58 -0
- pi_durable/py.typed +0 -0
- pi_durable/search.py +115 -0
- pi_durable/session/__init__.py +5 -0
- pi_durable/session/drafts.py +762 -0
- pi_durable/session/forks.py +109 -0
- pi_durable/session/observation.py +374 -0
- pi_durable/session/session.py +1193 -0
- pi_durable/session/transaction.py +1088 -0
- pi_durable/storage/__init__.py +5 -0
- pi_durable/storage/jsonl/__init__.py +15 -0
- pi_durable/storage/jsonl/storage.py +958 -0
- pi_durable/storage/memory.py +889 -0
- pi_durable/storage/scan.py +57 -0
- pi_durable/storage/sqlite/__init__.py +26 -0
- pi_durable/storage/sqlite/database.py +62 -0
- pi_durable/storage/sqlite/migrations.py +191 -0
- pi_durable/storage/sqlite/posix.py +314 -0
- pi_durable/storage/sqlite/schema.py +97 -0
- pi_durable/storage/sqlite/storage.py +1130 -0
- pi_durable/tasks.py +89 -0
- pi_durable/testing/__init__.py +41 -0
- pi_durable/testing/assertions.py +44 -0
- pi_durable/testing/env_conformance.py +828 -0
- pi_durable/testing/runner.py +64 -0
- pi_durable/testing/storage_conformance.py +2008 -0
- pi_durable/testing/types.py +99 -0
- pi_durable/tool_search/__init__.py +159 -0
- pi_durable/tools/__init__.py +81 -0
- pi_durable/tools/_values.py +43 -0
- pi_durable/tools/bash.py +250 -0
- pi_durable/tools/edit.py +214 -0
- pi_durable/tools/edit_diff.py +452 -0
- pi_durable/tools/env.py +19 -0
- pi_durable/tools/file_mutation_queue.py +68 -0
- pi_durable/tools/image.py +248 -0
- pi_durable/tools/image_processor.py +77 -0
- pi_durable/tools/path_utils.py +111 -0
- pi_durable/tools/read.py +506 -0
- pi_durable/tools/write.py +71 -0
- pi_durable/truncate.py +185 -0
- pi_durable/types.py +1297 -0
- pi_durable_python-1.3.0.dist-info/METADATA +276 -0
- pi_durable_python-1.3.0.dist-info/RECORD +90 -0
- pi_durable_python-1.3.0.dist-info/WHEEL +4 -0
- pi_durable_python-1.3.0.dist-info/licenses/LICENSE +21 -0
pi_durable/delta.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""Chord-compatible JSON operation batches.
|
|
2
|
+
|
|
3
|
+
Durable documents are materialized by replaying ordered operation batches. The tuple vocabulary
|
|
4
|
+
is the one Chord Delta publishes:
|
|
5
|
+
|
|
6
|
+
``["r", value]``
|
|
7
|
+
Replace the complete value.
|
|
8
|
+
``["s", path, value]``
|
|
9
|
+
Set an object property or array element.
|
|
10
|
+
``["d", path]``
|
|
11
|
+
Delete an object property or remove an array element.
|
|
12
|
+
``["a", path, text]``
|
|
13
|
+
Append to a string.
|
|
14
|
+
``["t", path, count]``
|
|
15
|
+
Remove ``count`` UTF-16 code units from a string's front.
|
|
16
|
+
``["p", path, index, remove, items]``
|
|
17
|
+
Splice an array.
|
|
18
|
+
``["m", path, permutation]``
|
|
19
|
+
Reorder an array in place: ``new[i] = old[permutation[i]]``.
|
|
20
|
+
|
|
21
|
+
The applier is intentionally independent of the document tracker. Memory storage uses it to
|
|
22
|
+
materialize persisted deltas; Session drafts emit the same tuples from their own overlay.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from collections.abc import Iterable, Sequence
|
|
28
|
+
from typing import Any, TypeAlias, cast
|
|
29
|
+
|
|
30
|
+
from .json import JsonValue, copy_json
|
|
31
|
+
|
|
32
|
+
Op: TypeAlias = list[Any]
|
|
33
|
+
"""One decoded operation tuple, represented as its JSON array."""
|
|
34
|
+
|
|
35
|
+
RESERVED_SEGMENTS = frozenset({"__proto__", "constructor", "prototype"})
|
|
36
|
+
|
|
37
|
+
MAX_DELTA_OPERATIONS = 4_096
|
|
38
|
+
"""Beyond this many operations a batch folds into one root replacement, as Chord does."""
|
|
39
|
+
|
|
40
|
+
OVERLAP_SCAN_LIMIT = 65_536
|
|
41
|
+
"""Characters Chord's bounded overlap search examines; beyond it a string is written whole."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class UnsafePathError(Exception):
|
|
45
|
+
"""An operation addresses an unsafe or malformed path segment."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, segment: object) -> None:
|
|
48
|
+
super().__init__(f"unsafe path segment: {segment}")
|
|
49
|
+
self.segment = segment
|
|
50
|
+
self.name = "UnsafePathError"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class PathError(Exception):
|
|
54
|
+
"""An operation cannot resolve to a value of the shape it expects."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, path: object) -> None:
|
|
57
|
+
super().__init__(f"unresolvable path: {path}")
|
|
58
|
+
self.path = path
|
|
59
|
+
self.name = "PathError"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _is_int(value: object) -> bool:
|
|
63
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def overlap_strings(before: str, after: str, limit: int = OVERLAP_SCAN_LIMIT) -> int:
|
|
67
|
+
"""Longest suffix of ``before`` that is a prefix of ``after``, within ``limit`` characters.
|
|
68
|
+
|
|
69
|
+
Knuth–Morris–Pratt, so a large tail window costs one pass; Chord's delta writer uses the same
|
|
70
|
+
bounded search and falls back to writing the whole string when it finds no shared part.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
if not before or not after or limit <= 0:
|
|
74
|
+
return 0
|
|
75
|
+
prefix = after[:limit]
|
|
76
|
+
combined = prefix + "\x00" + before
|
|
77
|
+
table = [0] * len(combined)
|
|
78
|
+
length = 0
|
|
79
|
+
for index in range(1, len(combined)):
|
|
80
|
+
while length > 0 and combined[index] != combined[length]:
|
|
81
|
+
length = table[length - 1]
|
|
82
|
+
if combined[index] == combined[length]:
|
|
83
|
+
length += 1
|
|
84
|
+
table[index] = length
|
|
85
|
+
return min(table[-1], len(prefix), len(before))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def assert_safe_path(path: object) -> None:
|
|
89
|
+
"""Reject segments that could reach the prototype chain or are not JSON path segments."""
|
|
90
|
+
|
|
91
|
+
if not isinstance(path, list):
|
|
92
|
+
raise TypeError("path is not an array")
|
|
93
|
+
for segment in path:
|
|
94
|
+
if isinstance(segment, str):
|
|
95
|
+
if segment in RESERVED_SEGMENTS:
|
|
96
|
+
raise UnsafePathError(segment)
|
|
97
|
+
elif not _is_int(segment) or segment < 0:
|
|
98
|
+
raise UnsafePathError(segment)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _assert_path_arg(value: object, *, non_empty: bool = False) -> None:
|
|
102
|
+
if not isinstance(value, list):
|
|
103
|
+
raise TypeError("path is not an array")
|
|
104
|
+
if non_empty and not value:
|
|
105
|
+
raise TypeError("path is empty")
|
|
106
|
+
assert_safe_path(value)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _assert_permutation(value: object) -> None:
|
|
110
|
+
if not isinstance(value, list):
|
|
111
|
+
raise TypeError("m permutation is not an array")
|
|
112
|
+
seen = [False] * len(value)
|
|
113
|
+
for index in value:
|
|
114
|
+
if not _is_int(index) or index < 0 or index >= len(value) or seen[index]:
|
|
115
|
+
raise TypeError("m permutation is not a bijection")
|
|
116
|
+
seen[index] = True
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def assert_valid_op(op: object) -> None:
|
|
120
|
+
"""Validate one decoded operation tuple."""
|
|
121
|
+
|
|
122
|
+
if not isinstance(op, list) or not op:
|
|
123
|
+
raise TypeError("op is not a tuple")
|
|
124
|
+
verb = op[0]
|
|
125
|
+
if verb == "r":
|
|
126
|
+
if len(op) != 2:
|
|
127
|
+
raise TypeError("r arity")
|
|
128
|
+
elif verb == "s":
|
|
129
|
+
if len(op) != 3:
|
|
130
|
+
raise TypeError("s arity")
|
|
131
|
+
_assert_path_arg(op[1], non_empty=True)
|
|
132
|
+
elif verb == "d":
|
|
133
|
+
if len(op) != 2:
|
|
134
|
+
raise TypeError("d arity")
|
|
135
|
+
_assert_path_arg(op[1], non_empty=True)
|
|
136
|
+
elif verb == "a":
|
|
137
|
+
if len(op) != 3 or not isinstance(op[2], str):
|
|
138
|
+
raise TypeError("a shape")
|
|
139
|
+
_assert_path_arg(op[1], non_empty=True)
|
|
140
|
+
elif verb == "t":
|
|
141
|
+
if len(op) != 3 or not _is_int(op[2]) or op[2] < 0:
|
|
142
|
+
raise TypeError("t shape")
|
|
143
|
+
_assert_path_arg(op[1], non_empty=True)
|
|
144
|
+
elif verb == "p":
|
|
145
|
+
if len(op) != 5:
|
|
146
|
+
raise TypeError("p arity")
|
|
147
|
+
_assert_path_arg(op[1])
|
|
148
|
+
if not _is_int(op[2]) or op[2] < 0:
|
|
149
|
+
raise TypeError("p index")
|
|
150
|
+
if not _is_int(op[3]) or op[3] < 0:
|
|
151
|
+
raise TypeError("p remove")
|
|
152
|
+
if not isinstance(op[4], list):
|
|
153
|
+
raise TypeError("p items")
|
|
154
|
+
elif verb == "m":
|
|
155
|
+
if len(op) != 3:
|
|
156
|
+
raise TypeError("m arity")
|
|
157
|
+
_assert_path_arg(op[1])
|
|
158
|
+
_assert_permutation(op[2])
|
|
159
|
+
else:
|
|
160
|
+
raise TypeError(f"unknown op verb: {verb}")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _resolve(root: JsonValue, path: Sequence[Any]) -> Any:
|
|
164
|
+
value: Any = root
|
|
165
|
+
for segment in path:
|
|
166
|
+
if isinstance(segment, str):
|
|
167
|
+
if not isinstance(value, dict):
|
|
168
|
+
raise PathError(list(path))
|
|
169
|
+
value = value[segment]
|
|
170
|
+
else:
|
|
171
|
+
if not isinstance(value, list) or segment >= len(value):
|
|
172
|
+
raise PathError(list(path))
|
|
173
|
+
value = value[segment]
|
|
174
|
+
return value
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _assert_index_in_range(parent: list[Any], index: int) -> None:
|
|
178
|
+
if index > len(parent):
|
|
179
|
+
raise UnsafePathError(index)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def apply(target: JsonValue, ops: Iterable[Op]) -> JsonValue:
|
|
183
|
+
"""Apply decoded operations to a mutable value and return the possibly replaced root.
|
|
184
|
+
|
|
185
|
+
Payloads carried by an operation are copied into the result, so the result never aliases the
|
|
186
|
+
caller's operations and later operations can safely address what an earlier one inserted.
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
root: Any = target
|
|
190
|
+
for op in ops:
|
|
191
|
+
assert_valid_op(op)
|
|
192
|
+
if op[0] == "r":
|
|
193
|
+
root = copy_json(op[1])
|
|
194
|
+
continue
|
|
195
|
+
path = op[1]
|
|
196
|
+
assert_safe_path(path)
|
|
197
|
+
if op[0] == "p":
|
|
198
|
+
parent = root if not path else _resolve(root, path)
|
|
199
|
+
if not isinstance(parent, list):
|
|
200
|
+
raise PathError(path)
|
|
201
|
+
index, remove, items = op[2], op[3], op[4]
|
|
202
|
+
parent[index:index + remove] = cast(list[Any], copy_json(items))
|
|
203
|
+
continue
|
|
204
|
+
if op[0] == "m":
|
|
205
|
+
parent = root if not path else _resolve(root, path)
|
|
206
|
+
if not isinstance(parent, list) or len(parent) != len(op[2]):
|
|
207
|
+
raise PathError(path)
|
|
208
|
+
previous = parent[:]
|
|
209
|
+
for index, source in enumerate(op[2]):
|
|
210
|
+
parent[index] = previous[source]
|
|
211
|
+
continue
|
|
212
|
+
|
|
213
|
+
parent = _resolve(root, path[:-1])
|
|
214
|
+
key = path[-1]
|
|
215
|
+
if isinstance(parent, list):
|
|
216
|
+
if not _is_int(key):
|
|
217
|
+
raise UnsafePathError(key)
|
|
218
|
+
_assert_index_in_range(parent, key)
|
|
219
|
+
if not isinstance(parent, (dict, list)):
|
|
220
|
+
raise PathError(path)
|
|
221
|
+
if op[0] == "s":
|
|
222
|
+
parent[key] = copy_json(op[2])
|
|
223
|
+
elif op[0] == "d":
|
|
224
|
+
if isinstance(parent, list):
|
|
225
|
+
if key >= len(parent):
|
|
226
|
+
raise PathError(path)
|
|
227
|
+
del parent[key]
|
|
228
|
+
else:
|
|
229
|
+
parent.pop(key, None)
|
|
230
|
+
elif op[0] == "a":
|
|
231
|
+
current = parent[key] if isinstance(parent, list) or key in parent else None
|
|
232
|
+
if not isinstance(current, str):
|
|
233
|
+
raise PathError(path)
|
|
234
|
+
parent[key] = current + op[2]
|
|
235
|
+
elif op[0] == "t":
|
|
236
|
+
current = parent[key] if isinstance(parent, list) or key in parent else None
|
|
237
|
+
if not isinstance(current, str):
|
|
238
|
+
raise PathError(path)
|
|
239
|
+
parent[key] = current[op[2]:]
|
|
240
|
+
return root
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def apply_immutable(target: JsonValue, ops: Sequence[Op]) -> JsonValue:
|
|
244
|
+
"""Apply one batch without mutating ``target`` or its containers."""
|
|
245
|
+
|
|
246
|
+
return apply(copy_json(target), ops)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def apply_immutable_batches(target: JsonValue, batches: Iterable[Sequence[Op]]) -> JsonValue:
|
|
250
|
+
"""Apply ordered batches without exposing or mutating an intermediate revision."""
|
|
251
|
+
|
|
252
|
+
root = copy_json(target)
|
|
253
|
+
for ops in batches:
|
|
254
|
+
root = apply(root, ops)
|
|
255
|
+
return root
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
__all__ = [
|
|
259
|
+
"MAX_DELTA_OPERATIONS",
|
|
260
|
+
"Op",
|
|
261
|
+
"PathError",
|
|
262
|
+
"RESERVED_SEGMENTS",
|
|
263
|
+
"UnsafePathError",
|
|
264
|
+
"apply",
|
|
265
|
+
"apply_immutable",
|
|
266
|
+
"apply_immutable_batches",
|
|
267
|
+
"assert_safe_path",
|
|
268
|
+
"assert_valid_op",
|
|
269
|
+
]
|
pi_durable/documents.py
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
"""Document definitions, typed tokens, and materialization.
|
|
2
|
+
|
|
3
|
+
A document definition is a token-safe value object. Persisted records keep the v1.3.0 camelCase
|
|
4
|
+
shape; Python callers configure definitions with snake_case names and may also pass a mapping.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import functools
|
|
10
|
+
import inspect
|
|
11
|
+
import json
|
|
12
|
+
from collections.abc import Callable, Mapping
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Any, Literal, TypeAlias, cast
|
|
15
|
+
|
|
16
|
+
from .delta import Op
|
|
17
|
+
from .ids import (
|
|
18
|
+
ConversationId,
|
|
19
|
+
DocumentId,
|
|
20
|
+
TaskId,
|
|
21
|
+
)
|
|
22
|
+
from .json import JsonObject, copy_json
|
|
23
|
+
from .types import CheckpointInfo, DocumentAddress, DocumentCreate, DocumentRecord, StoredDocument
|
|
24
|
+
|
|
25
|
+
DocScope: TypeAlias = Literal["session", "conversation", "task"]
|
|
26
|
+
|
|
27
|
+
#: Positional arities of the definition callbacks without the optional receiver.
|
|
28
|
+
_SINGLETON_INITIAL_ARITY = 0
|
|
29
|
+
_FAMILY_INITIAL_ARITY = 1
|
|
30
|
+
_MIGRATE_ARITY = 2
|
|
31
|
+
_CHECKPOINT_ARITY = 3
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class DocDefinition:
|
|
36
|
+
"""Erased document definition used by the Session after token resolution."""
|
|
37
|
+
|
|
38
|
+
kind: str
|
|
39
|
+
version: int
|
|
40
|
+
scope: DocScope
|
|
41
|
+
initial: Callable[..., JsonObject]
|
|
42
|
+
family: bool = False
|
|
43
|
+
history: Literal["latest", "rewindable"] | None = None
|
|
44
|
+
fork: Literal["asOf", "current", "initial"] | None = None
|
|
45
|
+
migrate: Callable[[JsonObject, int], JsonObject] | None = None
|
|
46
|
+
checkpoint_when: Callable[[JsonObject, list[Op], CheckpointInfo], bool] | None = None
|
|
47
|
+
|
|
48
|
+
def __getitem__(self, key: str) -> Any:
|
|
49
|
+
if key == "checkpointWhen":
|
|
50
|
+
return self.checkpoint_when
|
|
51
|
+
return getattr(self, key)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True, slots=True)
|
|
55
|
+
class DocToken:
|
|
56
|
+
"""Typed singleton document token passed explicitly to Session access."""
|
|
57
|
+
|
|
58
|
+
definition: DocDefinition
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True, slots=True)
|
|
62
|
+
class DocFamilyToken:
|
|
63
|
+
"""Typed keyed document-family token passed explicitly to Session access."""
|
|
64
|
+
|
|
65
|
+
definition: DocDefinition
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
AnyDocToken: TypeAlias = DocToken | DocFamilyToken
|
|
69
|
+
|
|
70
|
+
# Scope-specific token names from the v1.3.0 public surface. Python resolves the scope at runtime
|
|
71
|
+
# from the definition, so these are the same erased token classes.
|
|
72
|
+
SessionDocToken: TypeAlias = DocToken
|
|
73
|
+
ConversationDocToken: TypeAlias = DocToken
|
|
74
|
+
RewindableConversationDocToken: TypeAlias = DocToken
|
|
75
|
+
TaskDocToken: TypeAlias = DocToken
|
|
76
|
+
SessionDocFamilyToken: TypeAlias = DocFamilyToken
|
|
77
|
+
ConversationDocFamilyToken: TypeAlias = DocFamilyToken
|
|
78
|
+
RewindableConversationDocFamilyToken: TypeAlias = DocFamilyToken
|
|
79
|
+
TaskDocFamilyToken: TypeAlias = DocFamilyToken
|
|
80
|
+
|
|
81
|
+
#: Erased definition shapes used by callers that do not care about family/singleton tokens.
|
|
82
|
+
CommonDocDefinition: TypeAlias = DocDefinition
|
|
83
|
+
DocFamilyDefinition: TypeAlias = DocDefinition
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _definition_values(definition: Mapping[str, Any] | None, kwargs: Mapping[str, Any]) -> dict[str, Any]:
|
|
87
|
+
values = dict(definition or {})
|
|
88
|
+
values.update(kwargs)
|
|
89
|
+
aliases = {
|
|
90
|
+
"checkpointWhen": "checkpoint_when",
|
|
91
|
+
"initial": "initial",
|
|
92
|
+
}
|
|
93
|
+
for source, target in aliases.items():
|
|
94
|
+
if source in values and target not in values:
|
|
95
|
+
values[target] = values.pop(source)
|
|
96
|
+
return values
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _bind_receiver(
|
|
100
|
+
callback: Callable[..., Any] | None,
|
|
101
|
+
definition: DocDefinition,
|
|
102
|
+
arity: int,
|
|
103
|
+
) -> Callable[..., Any] | None:
|
|
104
|
+
"""Bind a definition callback that declares the definition as its receiver.
|
|
105
|
+
|
|
106
|
+
v1.3.0 invokes `initial`, `migrate`, and `checkpointWhen` as methods, so `this` is the
|
|
107
|
+
definition. Python callables are plain functions; one whose first parameter is named `self`
|
|
108
|
+
or `definition` and that declares exactly one extra positional parameter receives the
|
|
109
|
+
definition bound to it.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
if callback is None:
|
|
113
|
+
return None
|
|
114
|
+
try:
|
|
115
|
+
parameters = list(inspect.signature(callback).parameters.values())
|
|
116
|
+
except (TypeError, ValueError):
|
|
117
|
+
return callback
|
|
118
|
+
if any(parameter.kind is inspect.Parameter.VAR_POSITIONAL for parameter in parameters):
|
|
119
|
+
return callback
|
|
120
|
+
positional = [
|
|
121
|
+
parameter
|
|
122
|
+
for parameter in parameters
|
|
123
|
+
if parameter.kind
|
|
124
|
+
in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
|
|
125
|
+
]
|
|
126
|
+
if len(positional) != arity + 1:
|
|
127
|
+
return callback
|
|
128
|
+
if positional[0].name not in ("self", "definition"):
|
|
129
|
+
return callback
|
|
130
|
+
return functools.partial(callback, definition)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _build_definition(values: Mapping[str, Any], *, family: bool) -> DocDefinition:
|
|
134
|
+
kind = values.get("kind")
|
|
135
|
+
if not isinstance(kind, str):
|
|
136
|
+
raise TypeError("Document kind must be a string")
|
|
137
|
+
version = values.get("version")
|
|
138
|
+
if not isinstance(version, int) or isinstance(version, bool) or version < 1:
|
|
139
|
+
raise TypeError(f"Document {kind} version must be a positive integer")
|
|
140
|
+
scope = values.get("scope")
|
|
141
|
+
if scope not in ("session", "conversation", "task"):
|
|
142
|
+
raise TypeError(f"Document {kind} scope must be session, conversation, or task")
|
|
143
|
+
initial = values.get("initial")
|
|
144
|
+
if not callable(initial):
|
|
145
|
+
raise TypeError(f"Document {kind} requires an initial() function")
|
|
146
|
+
history = values.get("history")
|
|
147
|
+
fork = values.get("fork")
|
|
148
|
+
if scope == "conversation":
|
|
149
|
+
if history not in ("latest", "rewindable"):
|
|
150
|
+
raise TypeError(f"Conversation document {kind} requires history 'latest' or 'rewindable'")
|
|
151
|
+
if fork not in ("asOf", "current", "initial"):
|
|
152
|
+
raise TypeError(f"Conversation document {kind} requires a fork policy")
|
|
153
|
+
elif history is not None or fork is not None:
|
|
154
|
+
raise TypeError(f"Document {kind} history and fork apply only to conversation scope")
|
|
155
|
+
migrate = values.get("migrate")
|
|
156
|
+
if migrate is not None and not callable(migrate):
|
|
157
|
+
raise TypeError(f"Document {kind} migrate must be callable")
|
|
158
|
+
checkpoint_when = values.get("checkpoint_when")
|
|
159
|
+
if checkpoint_when is not None and not callable(checkpoint_when):
|
|
160
|
+
raise TypeError(f"Document {kind} checkpoint_when must be callable")
|
|
161
|
+
definition = DocDefinition(
|
|
162
|
+
kind=kind,
|
|
163
|
+
version=version,
|
|
164
|
+
scope=scope,
|
|
165
|
+
initial=initial,
|
|
166
|
+
family=family,
|
|
167
|
+
history=history,
|
|
168
|
+
fork=fork,
|
|
169
|
+
migrate=migrate,
|
|
170
|
+
checkpoint_when=checkpoint_when,
|
|
171
|
+
)
|
|
172
|
+
# Bind definition callbacks that declare the definition as their receiver, after constructing
|
|
173
|
+
# the final value so the receiver is the token's own definition.
|
|
174
|
+
object.__setattr__(
|
|
175
|
+
definition,
|
|
176
|
+
"initial",
|
|
177
|
+
_bind_receiver(
|
|
178
|
+
initial, definition, _FAMILY_INITIAL_ARITY if family else _SINGLETON_INITIAL_ARITY
|
|
179
|
+
),
|
|
180
|
+
)
|
|
181
|
+
object.__setattr__(
|
|
182
|
+
definition, "migrate", _bind_receiver(migrate, definition, _MIGRATE_ARITY)
|
|
183
|
+
)
|
|
184
|
+
object.__setattr__(
|
|
185
|
+
definition,
|
|
186
|
+
"checkpoint_when",
|
|
187
|
+
_bind_receiver(checkpoint_when, definition, _CHECKPOINT_ARITY),
|
|
188
|
+
)
|
|
189
|
+
return definition
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def define_doc(
|
|
193
|
+
definition: Mapping[str, Any] | None = None,
|
|
194
|
+
/,
|
|
195
|
+
**kwargs: Any,
|
|
196
|
+
) -> DocToken:
|
|
197
|
+
"""Define a Session-, conversation-, or task-scoped singleton document."""
|
|
198
|
+
|
|
199
|
+
return DocToken(_build_definition(_definition_values(definition, kwargs), family=False))
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def define_doc_family(
|
|
203
|
+
definition: Mapping[str, Any] | None = None,
|
|
204
|
+
/,
|
|
205
|
+
**kwargs: Any,
|
|
206
|
+
) -> DocFamilyToken:
|
|
207
|
+
"""Define a keyed Session-, conversation-, or task-scoped document family."""
|
|
208
|
+
|
|
209
|
+
values = _definition_values(definition, kwargs)
|
|
210
|
+
values.pop("family", None)
|
|
211
|
+
return DocFamilyToken(_build_definition(values, family=True))
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@dataclass(frozen=True, slots=True)
|
|
215
|
+
class ResolvedAddress:
|
|
216
|
+
"""Logical address plus its stable cache identity and consumed argument count."""
|
|
217
|
+
|
|
218
|
+
address: DocumentAddress
|
|
219
|
+
id: str
|
|
220
|
+
next_argument: int
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _owner_id(value: object, definition: DocDefinition, scope: str) -> int:
|
|
224
|
+
if not isinstance(value, int) or isinstance(value, bool) or value < 1:
|
|
225
|
+
raise TypeError(f"Document {definition.kind} requires a {scope} ID")
|
|
226
|
+
return value
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def resolve_address(definition: DocDefinition, args: tuple[Any, ...] | list[Any]) -> ResolvedAddress:
|
|
230
|
+
"""Resolve an overloaded argument list and return the index after owner and family key."""
|
|
231
|
+
|
|
232
|
+
index = 0
|
|
233
|
+
if definition.scope == "session":
|
|
234
|
+
scope: dict[str, Any] = {"kind": "session"}
|
|
235
|
+
elif definition.scope == "conversation":
|
|
236
|
+
scope = {
|
|
237
|
+
"kind": "conversation",
|
|
238
|
+
"conversationId": cast(ConversationId, _owner_id(args[index], definition, "conversation")),
|
|
239
|
+
}
|
|
240
|
+
index += 1
|
|
241
|
+
else:
|
|
242
|
+
scope = {"kind": "task", "taskId": cast(TaskId, _owner_id(args[index], definition, "task"))}
|
|
243
|
+
index += 1
|
|
244
|
+
key: str | None = None
|
|
245
|
+
if definition.family:
|
|
246
|
+
if index >= len(args) or not isinstance(args[index], str):
|
|
247
|
+
raise TypeError(f"Document family {definition.kind} requires a string key")
|
|
248
|
+
key = args[index]
|
|
249
|
+
index += 1
|
|
250
|
+
address: DocumentAddress = {"kind": definition.kind, "scope": cast(Any, scope)}
|
|
251
|
+
if key is not None:
|
|
252
|
+
address["key"] = key
|
|
253
|
+
return ResolvedAddress(address=address, id=address_id(address), next_argument=index)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def address_id(address: DocumentAddress) -> str:
|
|
257
|
+
"""Return the stable string identity of one logical address."""
|
|
258
|
+
|
|
259
|
+
scope = address["scope"]
|
|
260
|
+
owner = None if scope["kind"] == "session" else scope.get("conversationId", scope.get("taskId"))
|
|
261
|
+
return json.dumps(
|
|
262
|
+
[address["kind"], scope["kind"], owner, address.get("key")],
|
|
263
|
+
separators=(",", ":"),
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def document_create(
|
|
268
|
+
definition: DocDefinition, address: DocumentAddress, document_id: DocumentId
|
|
269
|
+
) -> DocumentCreate:
|
|
270
|
+
"""Build the storage create record for a new incarnation at an address."""
|
|
271
|
+
|
|
272
|
+
record: dict[str, Any] = {
|
|
273
|
+
"id": document_id,
|
|
274
|
+
"kind": address["kind"],
|
|
275
|
+
"scope": copy_json(address["scope"]),
|
|
276
|
+
}
|
|
277
|
+
if "key" in address:
|
|
278
|
+
record["key"] = address["key"]
|
|
279
|
+
if address["scope"]["kind"] == "conversation":
|
|
280
|
+
record["history"] = definition.history
|
|
281
|
+
record["fork"] = definition.fork
|
|
282
|
+
return cast(DocumentCreate, record)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def check_record_scope(definition: DocDefinition, record: DocumentCreate | DocumentRecord) -> None:
|
|
286
|
+
"""Reject typed access whose token disagrees with the persisted scope and semantics."""
|
|
287
|
+
|
|
288
|
+
scope = record["scope"]
|
|
289
|
+
if scope["kind"] != definition.scope:
|
|
290
|
+
raise TypeError(
|
|
291
|
+
f"Document {record['id']} ({record['kind']}) does not match the supplied definition semantics"
|
|
292
|
+
)
|
|
293
|
+
if scope["kind"] == "conversation":
|
|
294
|
+
if record.get("history") != definition.history or record.get("fork") != definition.fork:
|
|
295
|
+
raise TypeError(
|
|
296
|
+
f"Document {record['id']} ({record['kind']}) does not match the supplied definition semantics"
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def check_record_version(
|
|
301
|
+
definition: DocDefinition,
|
|
302
|
+
record: DocumentCreate | DocumentRecord,
|
|
303
|
+
version: int,
|
|
304
|
+
) -> None:
|
|
305
|
+
"""Reject typed access to a stored version the supplied definition cannot use."""
|
|
306
|
+
|
|
307
|
+
if version > definition.version:
|
|
308
|
+
raise ValueError(
|
|
309
|
+
f"Document {record['id']} ({record['kind']}) has newer version {version} "
|
|
310
|
+
f"than {definition.version}"
|
|
311
|
+
)
|
|
312
|
+
if version < definition.version and definition.migrate is None:
|
|
313
|
+
raise ValueError(
|
|
314
|
+
f"Document {record['id']} ({record['kind']}) requires migration from version {version}"
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def materialize_document_value(
|
|
319
|
+
definition: DocDefinition,
|
|
320
|
+
record: DocumentCreate | DocumentRecord,
|
|
321
|
+
version: int,
|
|
322
|
+
value: JsonObject,
|
|
323
|
+
) -> JsonObject:
|
|
324
|
+
"""Validate and materialize one detached value for typed access."""
|
|
325
|
+
|
|
326
|
+
check_record_scope(definition, record)
|
|
327
|
+
check_record_version(definition, record, version)
|
|
328
|
+
if version == definition.version:
|
|
329
|
+
return value
|
|
330
|
+
assert definition.migrate is not None
|
|
331
|
+
return cast(JsonObject, copy_json(definition.migrate(cast(JsonObject, copy_json(value)), version)))
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def materialize_document(definition: DocDefinition, stored: StoredDocument) -> JsonObject:
|
|
335
|
+
"""Validate and materialize one detached stored document."""
|
|
336
|
+
|
|
337
|
+
return materialize_document_value(definition, stored["record"], stored["version"], stored["value"])
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
__all__ = [
|
|
341
|
+
"AnyDocToken",
|
|
342
|
+
"CommonDocDefinition",
|
|
343
|
+
"ConversationDocFamilyToken",
|
|
344
|
+
"ConversationDocToken",
|
|
345
|
+
"DocDefinition",
|
|
346
|
+
"DocFamilyDefinition",
|
|
347
|
+
"DocFamilyToken",
|
|
348
|
+
"DocScope",
|
|
349
|
+
"DocToken",
|
|
350
|
+
"ResolvedAddress",
|
|
351
|
+
"RewindableConversationDocFamilyToken",
|
|
352
|
+
"RewindableConversationDocToken",
|
|
353
|
+
"SessionDocFamilyToken",
|
|
354
|
+
"SessionDocToken",
|
|
355
|
+
"TaskDocFamilyToken",
|
|
356
|
+
"TaskDocToken",
|
|
357
|
+
"address_id",
|
|
358
|
+
"check_record_scope",
|
|
359
|
+
"check_record_version",
|
|
360
|
+
"define_doc",
|
|
361
|
+
"define_doc_family",
|
|
362
|
+
"document_create",
|
|
363
|
+
"materialize_document",
|
|
364
|
+
"materialize_document_value",
|
|
365
|
+
"resolve_address",
|
|
366
|
+
]
|
pi_durable/entries.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Typed entry kinds.
|
|
2
|
+
|
|
3
|
+
An ``Entry`` token carries the persisted discriminator and narrows an entry record by ``kind``.
|
|
4
|
+
The built-in tokens name the transcript entries durable itself writes; applications define their
|
|
5
|
+
own with :func:`define_entry`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any, Generic, Never, TypeGuard, TypeVar, cast
|
|
13
|
+
|
|
14
|
+
from .json import JsonValue
|
|
15
|
+
from .types import TypedEntry
|
|
16
|
+
|
|
17
|
+
D = TypeVar("D", bound=JsonValue)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class Entry(Generic[D]):
|
|
22
|
+
"""A typed entry kind with a narrowing guard for its ``data`` payload."""
|
|
23
|
+
|
|
24
|
+
kind: str
|
|
25
|
+
|
|
26
|
+
def is_(self, entry: Mapping[str, Any] | None) -> TypeGuard[TypedEntry[D]]:
|
|
27
|
+
"""Return whether ``entry`` is present and has this token's kind."""
|
|
28
|
+
|
|
29
|
+
return entry is not None and entry.get("kind") == self.kind
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def define_entry(kind: str) -> Entry[D]:
|
|
33
|
+
"""Define a typed entry kind whose guard narrows by ``EntryRecord.kind``."""
|
|
34
|
+
|
|
35
|
+
if not isinstance(kind, str) or not kind:
|
|
36
|
+
raise TypeError("Entry kind must be a non-empty string")
|
|
37
|
+
return cast(Entry[D], Entry(kind))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
#: User input: ``model`` is ``[UserMessage]``. Written by submissions.
|
|
41
|
+
UserEntry: Entry[Never] = define_entry("pi.user")
|
|
42
|
+
#: Provider result with any stop reason: ``model`` is ``[AssistantMessage]``. Written by generation.
|
|
43
|
+
AssistantEntry: Entry[Never] = define_entry("pi.assistant")
|
|
44
|
+
#: Positional prompt and tool change: ``model`` is ``[SystemMessage]`` with empty ``content``.
|
|
45
|
+
SystemEntry: Entry[Never] = define_entry("pi.system")
|
|
46
|
+
#: Tool result: ``model`` is ``[ToolResultMessage]``, whose content ends with the rendered
|
|
47
|
+
#: diagnostics block; ``data`` holds the structured diagnostics, possibly none.
|
|
48
|
+
ToolResultEntry: Entry[dict[str, JsonValue]] = define_entry("pi.tool-result")
|
|
49
|
+
#: Start of a new context: always ``head: "self"``, with ``model`` absent for a plain reset or
|
|
50
|
+
#: ``[UserMessage]`` carrying the handoff text.
|
|
51
|
+
ResetEntry: Entry[Never] = define_entry("pi.reset")
|
|
52
|
+
#: Compaction summary: ``model`` is ``[UserMessage]`` with the wrapped summary, ``head`` the first
|
|
53
|
+
#: kept entry.
|
|
54
|
+
CompactionEntry: Entry[dict[str, JsonValue]] = define_entry("pi.compaction")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
__all__ = [
|
|
58
|
+
"AssistantEntry",
|
|
59
|
+
"CompactionEntry",
|
|
60
|
+
"Entry",
|
|
61
|
+
"ResetEntry",
|
|
62
|
+
"SystemEntry",
|
|
63
|
+
"ToolResultEntry",
|
|
64
|
+
"UserEntry",
|
|
65
|
+
"define_entry",
|
|
66
|
+
]
|