sanctum-engine 0.2.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.
- sanctum/__init__.py +66 -0
- sanctum/aether/__init__.py +23 -0
- sanctum/aether/core.py +166 -0
- sanctum/aether/errors.py +13 -0
- sanctum/aether/reducers.py +43 -0
- sanctum/codex/__init__.py +21 -0
- sanctum/codex/core.py +60 -0
- sanctum/codex/errors.py +14 -0
- sanctum/codex/memory.py +33 -0
- sanctum/codex/postgres.py +164 -0
- sanctum/codex/sqlite.py +126 -0
- sanctum/grimoire/__init__.py +25 -0
- sanctum/grimoire/core.py +290 -0
- sanctum/grimoire/errors.py +35 -0
- sanctum/grimoire/repair.py +117 -0
- sanctum/grimoire/summon.py +211 -0
- sanctum/omens/__init__.py +50 -0
- sanctum/omens/events.py +218 -0
- sanctum/omens/tracing.py +455 -0
- sanctum/oracle/__init__.py +44 -0
- sanctum/oracle/_shared.py +105 -0
- sanctum/oracle/core.py +87 -0
- sanctum/oracle/errors.py +39 -0
- sanctum/oracle/llamacpp.py +154 -0
- sanctum/oracle/ollama.py +215 -0
- sanctum/oracle/openai_compat.py +227 -0
- sanctum/oracle/robust.py +239 -0
- sanctum/oracle/scripted.py +68 -0
- sanctum/oracle/transformers.py +89 -0
- sanctum/ritual/__init__.py +42 -0
- sanctum/ritual/constants.py +14 -0
- sanctum/ritual/core.py +624 -0
- sanctum/ritual/errors.py +58 -0
- sanctum/ritual/interrupt.py +42 -0
- sanctum/ritual/policies.py +80 -0
- sanctum/ritual/scheduler.py +573 -0
- sanctum/trace.py +41 -0
- sanctum/wards/__init__.py +24 -0
- sanctum/wards/audit.py +39 -0
- sanctum/wards/core.py +62 -0
- sanctum/wards/errors.py +14 -0
- sanctum/wards/redact.py +53 -0
- sanctum/wards/usage.py +63 -0
- sanctum_engine-0.2.0.dist-info/METADATA +175 -0
- sanctum_engine-0.2.0.dist-info/RECORD +47 -0
- sanctum_engine-0.2.0.dist-info/WHEEL +4 -0
- sanctum_engine-0.2.0.dist-info/licenses/LICENSE +21 -0
sanctum/__init__.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Sanctum — the chamber where rituals of invocation are prepared and performed.
|
|
2
|
+
|
|
3
|
+
A minimal, local-first orchestration engine for AI agents. Sanctum executes
|
|
4
|
+
cyclic state graphs by supersteps (Pregel/BSP model): Sigils (nodes) run in
|
|
5
|
+
parallel over a shared Aether (state), their partial deltas are merged through
|
|
6
|
+
each Conduit's reducer, and conditional edges decide the next active set until
|
|
7
|
+
END or the recursion limit is reached.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from sanctum.aether import AetherSchema, AetherValidationError, Conduit
|
|
11
|
+
from sanctum.codex import Codex, Seal, SealError
|
|
12
|
+
from sanctum.grimoire import (
|
|
13
|
+
Spell,
|
|
14
|
+
SpellCallParseError,
|
|
15
|
+
SpellExecutionError,
|
|
16
|
+
Tome,
|
|
17
|
+
spell,
|
|
18
|
+
summon,
|
|
19
|
+
)
|
|
20
|
+
from sanctum.oracle import Oracle
|
|
21
|
+
from sanctum.ritual import (
|
|
22
|
+
END,
|
|
23
|
+
START,
|
|
24
|
+
Interrupt,
|
|
25
|
+
RecursionLimitError,
|
|
26
|
+
Rite,
|
|
27
|
+
Ritual,
|
|
28
|
+
RitualValidationError,
|
|
29
|
+
SigilExecutionError,
|
|
30
|
+
SigilPolicy,
|
|
31
|
+
SigilTimeoutError,
|
|
32
|
+
interrupt,
|
|
33
|
+
)
|
|
34
|
+
from sanctum.wards import Ward, WardRejection
|
|
35
|
+
|
|
36
|
+
__version__ = "0.2.0"
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"END",
|
|
40
|
+
"START",
|
|
41
|
+
"AetherSchema",
|
|
42
|
+
"AetherValidationError",
|
|
43
|
+
"Codex",
|
|
44
|
+
"Conduit",
|
|
45
|
+
"Interrupt",
|
|
46
|
+
"Oracle",
|
|
47
|
+
"RecursionLimitError",
|
|
48
|
+
"Rite",
|
|
49
|
+
"Ritual",
|
|
50
|
+
"RitualValidationError",
|
|
51
|
+
"Seal",
|
|
52
|
+
"SealError",
|
|
53
|
+
"SigilExecutionError",
|
|
54
|
+
"SigilPolicy",
|
|
55
|
+
"SigilTimeoutError",
|
|
56
|
+
"Spell",
|
|
57
|
+
"SpellCallParseError",
|
|
58
|
+
"SpellExecutionError",
|
|
59
|
+
"Tome",
|
|
60
|
+
"Ward",
|
|
61
|
+
"WardRejection",
|
|
62
|
+
"__version__",
|
|
63
|
+
"interrupt",
|
|
64
|
+
"spell",
|
|
65
|
+
"summon",
|
|
66
|
+
]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Aether — the shared energy all invoked entities draw upon.
|
|
2
|
+
|
|
3
|
+
Shared state management. The Aether is a typed dict whose channels are
|
|
4
|
+
Conduits, each declaring a reducer (overwrite, append, add, merge_dict, or
|
|
5
|
+
custom) that merges the partial deltas returned by Sigils. An AetherSchema
|
|
6
|
+
defines the Conduits available to a Ritual.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from sanctum.aether.core import Aether, AetherSchema, Conduit
|
|
10
|
+
from sanctum.aether.errors import AetherValidationError
|
|
11
|
+
from sanctum.aether.reducers import Reducer, add, append, merge_dict, overwrite
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"Aether",
|
|
15
|
+
"AetherSchema",
|
|
16
|
+
"AetherValidationError",
|
|
17
|
+
"Conduit",
|
|
18
|
+
"Reducer",
|
|
19
|
+
"add",
|
|
20
|
+
"append",
|
|
21
|
+
"merge_dict",
|
|
22
|
+
"overwrite",
|
|
23
|
+
]
|
sanctum/aether/core.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""The Conduits are drawn: the shape the shared energy must take.
|
|
2
|
+
|
|
3
|
+
State schema. A Conduit declares how one key of the Aether merges deltas
|
|
4
|
+
(its reducer); an AetherSchema is the complete set of Conduits a Ritual
|
|
5
|
+
operates on. When a Ritual has a schema, every delta key must name a
|
|
6
|
+
declared Conduit and is merged through that Conduit's reducer instead of
|
|
7
|
+
plain overwrite.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Mapping, Sequence
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Annotated, Any, get_args, get_origin, get_type_hints
|
|
15
|
+
|
|
16
|
+
from sanctum.aether.errors import AetherValidationError
|
|
17
|
+
from sanctum.aether.reducers import Reducer, overwrite
|
|
18
|
+
|
|
19
|
+
Aether = dict[str, Any]
|
|
20
|
+
"""The shared state dict, keyed by Conduit name."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class Conduit:
|
|
25
|
+
"""One channel of the Aether and the way it merges new energy.
|
|
26
|
+
|
|
27
|
+
Declares the reducer ``(current, update) -> new`` applied when a
|
|
28
|
+
Sigil's delta writes to this key. Defaults to `overwrite`. The reducer
|
|
29
|
+
is skipped when the key is not yet present in the Aether: the delta
|
|
30
|
+
value is set directly, so custom reducers never receive a missing
|
|
31
|
+
current value.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
reducer: Reducer = overwrite
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _as_conduit(value: Conduit | Any) -> Conduit:
|
|
38
|
+
"""Coerce a schema declaration into a Conduit.
|
|
39
|
+
|
|
40
|
+
Accepts a Conduit instance, an ``Annotated[T, reducer]`` (the first
|
|
41
|
+
callable metadata item becomes the reducer), or any plain type
|
|
42
|
+
(overwrite). Types themselves are not enforced at runtime; the schema
|
|
43
|
+
governs keys and reducers.
|
|
44
|
+
"""
|
|
45
|
+
if isinstance(value, Conduit):
|
|
46
|
+
return value
|
|
47
|
+
if get_origin(value) is Annotated:
|
|
48
|
+
for item in get_args(value)[1:]:
|
|
49
|
+
if callable(item):
|
|
50
|
+
return Conduit(reducer=item)
|
|
51
|
+
return Conduit()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class AetherSchema:
|
|
55
|
+
"""The declared shape of the shared energy.
|
|
56
|
+
|
|
57
|
+
Maps each Aether key to its Conduit. Values in the constructor mapping
|
|
58
|
+
may be Conduit instances, ``Annotated[T, reducer]`` forms, or plain
|
|
59
|
+
types (which default to overwrite):
|
|
60
|
+
|
|
61
|
+
AetherSchema({
|
|
62
|
+
"messages": Conduit(reducer=append),
|
|
63
|
+
"score": Annotated[int, add],
|
|
64
|
+
"verdict": str,
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
Delta application is deterministic: within a superstep, deltas are
|
|
68
|
+
applied in Sigil insertion order (the order Sigils were bound to the
|
|
69
|
+
Ritual), and each key merges through its Conduit's reducer.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(self, conduits: Mapping[str, Conduit | Any]) -> None:
|
|
73
|
+
self._conduits: dict[str, Conduit] = {
|
|
74
|
+
name: _as_conduit(value) for name, value in conduits.items()
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
def from_class(cls, source: type) -> AetherSchema:
|
|
79
|
+
"""Read the Conduits from a class's annotations.
|
|
80
|
+
|
|
81
|
+
Sugar for declaring the schema as an annotated class::
|
|
82
|
+
|
|
83
|
+
class ChantAether:
|
|
84
|
+
messages: Annotated[list, append]
|
|
85
|
+
score: int # plain annotation -> overwrite
|
|
86
|
+
|
|
87
|
+
``Annotated[T, reducer]`` fields use the first callable metadata
|
|
88
|
+
item as the Conduit's reducer.
|
|
89
|
+
"""
|
|
90
|
+
hints = get_type_hints(source, include_extras=True)
|
|
91
|
+
return cls(hints)
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def conduits(self) -> dict[str, Conduit]:
|
|
95
|
+
"""A copy of the mapping from Aether key to its Conduit."""
|
|
96
|
+
return dict(self._conduits)
|
|
97
|
+
|
|
98
|
+
def __contains__(self, key: str) -> bool:
|
|
99
|
+
return key in self._conduits
|
|
100
|
+
|
|
101
|
+
def validate_input(self, input: Mapping[str, Any]) -> None:
|
|
102
|
+
"""Check that the initial Aether only uses declared Conduits.
|
|
103
|
+
|
|
104
|
+
Raises:
|
|
105
|
+
AetherValidationError: If `input` contains keys not declared in
|
|
106
|
+
the schema.
|
|
107
|
+
"""
|
|
108
|
+
unknown = sorted(set(input) - set(self._conduits))
|
|
109
|
+
if unknown:
|
|
110
|
+
raise AetherValidationError(
|
|
111
|
+
f"Invocation input contains unknown Conduit(s): "
|
|
112
|
+
f"{', '.join(unknown)}; declared Conduits: "
|
|
113
|
+
f"{', '.join(sorted(self._conduits)) or '(none)'}."
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def apply_delta(
|
|
117
|
+
self, aether: Mapping[str, Any], delta: Mapping[str, Any], *, sigil: str
|
|
118
|
+
) -> Aether:
|
|
119
|
+
"""Merge one Sigil's delta into the Aether through the Conduits.
|
|
120
|
+
|
|
121
|
+
Each delta key must name a declared Conduit; its value merges via
|
|
122
|
+
the Conduit's reducer, or is set directly when the key is not yet
|
|
123
|
+
present. Returns a new dict; `aether` is not mutated.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
aether: Current state.
|
|
127
|
+
delta: Partial update returned by the Sigil.
|
|
128
|
+
sigil: Name of the Sigil that produced the delta, for error
|
|
129
|
+
attribution.
|
|
130
|
+
|
|
131
|
+
Raises:
|
|
132
|
+
AetherValidationError: If the delta writes to a key not
|
|
133
|
+
declared in the schema; the message names the Sigil.
|
|
134
|
+
"""
|
|
135
|
+
merged: Aether = dict(aether)
|
|
136
|
+
for key, update in delta.items():
|
|
137
|
+
if key not in self._conduits:
|
|
138
|
+
raise AetherValidationError(
|
|
139
|
+
f"Sigil '{sigil}' wrote to unknown Conduit '{key}'; "
|
|
140
|
+
f"declared Conduits: "
|
|
141
|
+
f"{', '.join(sorted(self._conduits)) or '(none)'}."
|
|
142
|
+
)
|
|
143
|
+
if key in merged:
|
|
144
|
+
merged[key] = self._conduits[key].reducer(merged[key], update)
|
|
145
|
+
else:
|
|
146
|
+
merged[key] = update
|
|
147
|
+
return merged
|
|
148
|
+
|
|
149
|
+
def apply_deltas(
|
|
150
|
+
self,
|
|
151
|
+
aether: Mapping[str, Any],
|
|
152
|
+
deltas: Sequence[tuple[str, Mapping[str, Any]]],
|
|
153
|
+
) -> Aether:
|
|
154
|
+
"""Merge one superstep's deltas into the Aether, deterministically.
|
|
155
|
+
|
|
156
|
+
Deltas are applied strictly in the order given as ``(sigil_name,
|
|
157
|
+
delta)`` pairs; the engine passes them in Sigil insertion order
|
|
158
|
+
(the order Sigils were bound to the Ritual). This makes concurrent
|
|
159
|
+
writes to the same Conduit within a superstep deterministic: the
|
|
160
|
+
Conduit's reducer folds each delta in, in that fixed order.
|
|
161
|
+
Returns a new dict; `aether` is not mutated.
|
|
162
|
+
"""
|
|
163
|
+
merged: Aether = dict(aether)
|
|
164
|
+
for sigil, delta in deltas:
|
|
165
|
+
merged = self.apply_delta(merged, delta, sigil=sigil)
|
|
166
|
+
return merged
|
sanctum/aether/errors.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""The ways the Aether rejects what does not belong to it.
|
|
2
|
+
|
|
3
|
+
Exceptions raised by the Aether layer.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class AetherValidationError(Exception):
|
|
8
|
+
"""Energy tried to flow outside the declared Conduits.
|
|
9
|
+
|
|
10
|
+
Raised at invocation time when a Sigil's delta — or the initial input —
|
|
11
|
+
writes to a key not declared in the AetherSchema. The message names the
|
|
12
|
+
offending Sigil (when one is involved) and the unknown key.
|
|
13
|
+
"""
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""How new energy folds into each Conduit of the Aether.
|
|
2
|
+
|
|
3
|
+
Built-in reducers. A reducer is any callable ``(current, update) -> new``
|
|
4
|
+
that merges a Sigil's delta value into a Conduit's current value:
|
|
5
|
+
`overwrite` (the default) replaces, `append` concatenates lists, `add` sums
|
|
6
|
+
numbers, and `merge_dict` shallow-merges dicts. Any custom callable with
|
|
7
|
+
the same signature is a valid reducer. Reducers are only called when the
|
|
8
|
+
Conduit already holds a value; a delta to a missing key sets it directly.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
Reducer = Callable[[Any, Any], Any]
|
|
15
|
+
"""A Conduit's merge function: ``(current, update) -> new value``."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def overwrite(current: Any, update: Any) -> Any:
|
|
19
|
+
"""Replace the current value with the update (the default reducer)."""
|
|
20
|
+
return update
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def append(current: list[Any], update: list[Any]) -> list[Any]:
|
|
24
|
+
"""Concatenate the update list after the current list.
|
|
25
|
+
|
|
26
|
+
The delta value must be a list of items to append (wrap single items).
|
|
27
|
+
Returns a new list; neither input is mutated.
|
|
28
|
+
"""
|
|
29
|
+
return list(current) + list(update)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def add(current: Any, update: Any) -> Any:
|
|
33
|
+
"""Sum the update into the current value (numeric accumulation)."""
|
|
34
|
+
return current + update
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def merge_dict(current: dict[str, Any], update: dict[str, Any]) -> dict[str, Any]:
|
|
38
|
+
"""Shallow-merge the update dict over the current dict.
|
|
39
|
+
|
|
40
|
+
Keys present in both take the update's value. Returns a new dict;
|
|
41
|
+
neither input is mutated.
|
|
42
|
+
"""
|
|
43
|
+
return {**current, **update}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Codex — the ledger where every step of the ritual is inscribed.
|
|
2
|
+
|
|
3
|
+
Persistence and checkpointing. A Seal is the snapshot of the Aether taken at
|
|
4
|
+
the end of each superstep; a Codex stores and retrieves Seals per Invocation
|
|
5
|
+
(execution session, keyed by `invocation_id`). Implementations: MemoryCodex,
|
|
6
|
+
SqliteCodex, and PostgresCodex (optional extra, lazy imports; import it
|
|
7
|
+
explicitly from `sanctum.codex.postgres`).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from sanctum.codex.core import Codex, Seal
|
|
11
|
+
from sanctum.codex.errors import SealError
|
|
12
|
+
from sanctum.codex.memory import MemoryCodex
|
|
13
|
+
from sanctum.codex.sqlite import SqliteCodex
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"Codex",
|
|
17
|
+
"MemoryCodex",
|
|
18
|
+
"Seal",
|
|
19
|
+
"SealError",
|
|
20
|
+
"SqliteCodex",
|
|
21
|
+
]
|
sanctum/codex/core.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Seals — the wax impressions each superstep leaves behind.
|
|
2
|
+
|
|
3
|
+
Checkpoint model and storage contract. A Seal is the snapshot written at
|
|
4
|
+
the end of a superstep: the full Aether, the frontier of the next
|
|
5
|
+
superstep, the superstep number, a timestamp, and free-form metadata. A
|
|
6
|
+
Codex stores Seals per Invocation (keyed by `invocation_id`) and yields
|
|
7
|
+
them back to enable resumption, human-in-the-loop pauses, and time-travel.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import time
|
|
13
|
+
import uuid
|
|
14
|
+
from abc import ABC, abstractmethod
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class Seal:
|
|
21
|
+
"""The wax impression of one superstep.
|
|
22
|
+
|
|
23
|
+
Immutable checkpoint. `aether` is the full state after the superstep's
|
|
24
|
+
deltas were applied; `frontier` the Sigils active in the *next*
|
|
25
|
+
superstep — resumption continues from it; `superstep` the 1-based
|
|
26
|
+
count within the Invocation; `timestamp` epoch seconds; `metadata`
|
|
27
|
+
free-form context (e.g. interrupt details). `seal_id` uniquely
|
|
28
|
+
identifies the Seal for time-travel.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
aether: dict[str, Any]
|
|
32
|
+
frontier: list[str]
|
|
33
|
+
superstep: int
|
|
34
|
+
timestamp: float = field(default_factory=time.time)
|
|
35
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
36
|
+
seal_id: str = field(default_factory=lambda: uuid.uuid4().hex)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Codex(ABC):
|
|
40
|
+
"""The ledger where Seals are inscribed and consulted.
|
|
41
|
+
|
|
42
|
+
Abstract async storage contract for Seals, keyed by `invocation_id`.
|
|
43
|
+
Histories are append-only: resuming an Invocation appends new Seals
|
|
44
|
+
after the old ones, and `get` returns the most recently written Seal
|
|
45
|
+
(not the highest superstep number). Implementations: MemoryCodex
|
|
46
|
+
(ephemeral, for tests), SqliteCodex (local file), PostgresCodex
|
|
47
|
+
(optional extra, `sanctum.codex.postgres`).
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
@abstractmethod
|
|
51
|
+
async def put(self, invocation_id: str, seal: Seal) -> None:
|
|
52
|
+
"""Append a Seal to the Invocation's history."""
|
|
53
|
+
|
|
54
|
+
@abstractmethod
|
|
55
|
+
async def get(self, invocation_id: str) -> Seal | None:
|
|
56
|
+
"""Return the Invocation's most recently written Seal, or None."""
|
|
57
|
+
|
|
58
|
+
@abstractmethod
|
|
59
|
+
async def list(self, invocation_id: str) -> list[Seal]:
|
|
60
|
+
"""Return the Invocation's full Seal history, oldest first."""
|
sanctum/codex/errors.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""The ways the ledger refuses an inscription or a consultation.
|
|
2
|
+
|
|
3
|
+
Exceptions raised by the Codex layer.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class SealError(Exception):
|
|
8
|
+
"""A Seal could not be written, found, or restored.
|
|
9
|
+
|
|
10
|
+
Raised when persistence fails (e.g. the Aether is not
|
|
11
|
+
JSON-serializable for a storage backend) or when resumption cannot
|
|
12
|
+
proceed: no Codex attached, no Seals recorded for the Invocation, or an
|
|
13
|
+
unknown `seal_id`.
|
|
14
|
+
"""
|
sanctum/codex/memory.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""A Codex written in breath: it fades when the session ends.
|
|
2
|
+
|
|
3
|
+
In-memory Seal store, the reference implementation for tests and
|
|
4
|
+
ephemeral invocations. Not shared across processes and not persistent.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from sanctum.codex.core import Codex, Seal
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MemoryCodex(Codex):
|
|
13
|
+
"""Ephemeral in-memory ledger of Seals.
|
|
14
|
+
|
|
15
|
+
Stores histories in a plain dict keyed by `invocation_id`. Intended
|
|
16
|
+
for tests and short-lived local runs; contents vanish with the object.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self) -> None:
|
|
20
|
+
self._seals: dict[str, list[Seal]] = {}
|
|
21
|
+
|
|
22
|
+
async def put(self, invocation_id: str, seal: Seal) -> None:
|
|
23
|
+
"""Append a Seal to the Invocation's history."""
|
|
24
|
+
self._seals.setdefault(invocation_id, []).append(seal)
|
|
25
|
+
|
|
26
|
+
async def get(self, invocation_id: str) -> Seal | None:
|
|
27
|
+
"""Return the Invocation's most recently written Seal, or None."""
|
|
28
|
+
history = self._seals.get(invocation_id)
|
|
29
|
+
return history[-1] if history else None
|
|
30
|
+
|
|
31
|
+
async def list(self, invocation_id: str) -> list[Seal]:
|
|
32
|
+
"""Return the Invocation's full Seal history, oldest first."""
|
|
33
|
+
return list(self._seals.get(invocation_id, ()))
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""A Codex kept in a distant archive: PostgreSQL.
|
|
2
|
+
|
|
3
|
+
Durable Seal store for multi-process or remote deployments. This is an
|
|
4
|
+
optional module: it requires the `psycopg` (v3) driver, declared as the
|
|
5
|
+
``postgres`` extra (``pip install sanctum-engine[postgres]``). The import
|
|
6
|
+
is lazy — importing this module never fails; instantiating PostgresCodex
|
|
7
|
+
without psycopg installed raises ImportError with install instructions.
|
|
8
|
+
|
|
9
|
+
Same contract and limitation as SqliteCodex: the Aether, frontier, and
|
|
10
|
+
metadata are stored as JSONB, so every value must be JSON-serializable.
|
|
11
|
+
|
|
12
|
+
Schema (created automatically on first use):
|
|
13
|
+
|
|
14
|
+
CREATE TABLE IF NOT EXISTS seals (
|
|
15
|
+
id BIGSERIAL PRIMARY KEY,
|
|
16
|
+
seal_id TEXT NOT NULL UNIQUE,
|
|
17
|
+
invocation_id TEXT NOT NULL,
|
|
18
|
+
superstep INTEGER NOT NULL,
|
|
19
|
+
aether JSONB NOT NULL,
|
|
20
|
+
frontier JSONB NOT NULL,
|
|
21
|
+
timestamp DOUBLE PRECISION NOT NULL,
|
|
22
|
+
metadata JSONB NOT NULL
|
|
23
|
+
);
|
|
24
|
+
CREATE INDEX IF NOT EXISTS seals_invocation_idx
|
|
25
|
+
ON seals (invocation_id, id);
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import json
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
from sanctum.codex.core import Codex, Seal
|
|
34
|
+
from sanctum.codex.errors import SealError
|
|
35
|
+
|
|
36
|
+
_SCHEMA = """
|
|
37
|
+
CREATE TABLE IF NOT EXISTS seals (
|
|
38
|
+
id BIGSERIAL PRIMARY KEY,
|
|
39
|
+
seal_id TEXT NOT NULL UNIQUE,
|
|
40
|
+
invocation_id TEXT NOT NULL,
|
|
41
|
+
superstep INTEGER NOT NULL,
|
|
42
|
+
aether JSONB NOT NULL,
|
|
43
|
+
frontier JSONB NOT NULL,
|
|
44
|
+
timestamp DOUBLE PRECISION NOT NULL,
|
|
45
|
+
metadata JSONB NOT NULL
|
|
46
|
+
);
|
|
47
|
+
CREATE INDEX IF NOT EXISTS seals_invocation_idx
|
|
48
|
+
ON seals (invocation_id, id);
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _psycopg() -> Any:
|
|
53
|
+
"""Import psycopg lazily, with install guidance on failure."""
|
|
54
|
+
try:
|
|
55
|
+
import psycopg
|
|
56
|
+
except ImportError as exc:
|
|
57
|
+
raise ImportError(
|
|
58
|
+
"PostgresCodex requires the optional dependency 'psycopg'; "
|
|
59
|
+
"install it with: pip install sanctum-engine[postgres]"
|
|
60
|
+
) from exc
|
|
61
|
+
return psycopg
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class PostgresCodex(Codex):
|
|
65
|
+
"""Durable PostgreSQL ledger of Seals.
|
|
66
|
+
|
|
67
|
+
Persists Seals via async psycopg connections opened per operation
|
|
68
|
+
against `dsn` (e.g. ``postgresql://user:pass@host/db``). The schema is
|
|
69
|
+
created on first use. Requires JSON-serializable Aether and metadata.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(self, dsn: str) -> None:
|
|
73
|
+
_psycopg() # fail fast, with guidance, if the driver is missing
|
|
74
|
+
self._dsn = dsn
|
|
75
|
+
self._schema_ready = False
|
|
76
|
+
|
|
77
|
+
async def _connect(self) -> Any:
|
|
78
|
+
"""Open an async connection, creating the schema on first use."""
|
|
79
|
+
connection = await _psycopg().AsyncConnection.connect(self._dsn)
|
|
80
|
+
if not self._schema_ready:
|
|
81
|
+
await connection.execute(_SCHEMA)
|
|
82
|
+
await connection.commit()
|
|
83
|
+
self._schema_ready = True
|
|
84
|
+
return connection
|
|
85
|
+
|
|
86
|
+
async def put(self, invocation_id: str, seal: Seal) -> None:
|
|
87
|
+
"""Append a Seal to the Invocation's history.
|
|
88
|
+
|
|
89
|
+
Raises:
|
|
90
|
+
SealError: If the Seal's Aether, frontier, or metadata cannot
|
|
91
|
+
be serialized to JSON.
|
|
92
|
+
"""
|
|
93
|
+
try:
|
|
94
|
+
aether = json.dumps(seal.aether)
|
|
95
|
+
frontier = json.dumps(seal.frontier)
|
|
96
|
+
metadata = json.dumps(seal.metadata)
|
|
97
|
+
except (TypeError, ValueError) as exc:
|
|
98
|
+
raise SealError(
|
|
99
|
+
f"Seal '{seal.seal_id}' of Invocation '{invocation_id}' is "
|
|
100
|
+
f"not JSON-serializable: {exc}. PostgresCodex requires the "
|
|
101
|
+
"Aether and metadata to hold only JSON-serializable values."
|
|
102
|
+
) from exc
|
|
103
|
+
connection = await self._connect()
|
|
104
|
+
try:
|
|
105
|
+
await connection.execute(
|
|
106
|
+
"INSERT INTO seals (seal_id, invocation_id, superstep, "
|
|
107
|
+
"aether, frontier, timestamp, metadata) "
|
|
108
|
+
"VALUES (%s, %s, %s, %s, %s, %s, %s)",
|
|
109
|
+
(
|
|
110
|
+
seal.seal_id,
|
|
111
|
+
invocation_id,
|
|
112
|
+
seal.superstep,
|
|
113
|
+
aether,
|
|
114
|
+
frontier,
|
|
115
|
+
seal.timestamp,
|
|
116
|
+
metadata,
|
|
117
|
+
),
|
|
118
|
+
)
|
|
119
|
+
await connection.commit()
|
|
120
|
+
finally:
|
|
121
|
+
await connection.close()
|
|
122
|
+
|
|
123
|
+
async def get(self, invocation_id: str) -> Seal | None:
|
|
124
|
+
"""Return the Invocation's most recently written Seal, or None."""
|
|
125
|
+
connection = await self._connect()
|
|
126
|
+
try:
|
|
127
|
+
cursor = await connection.execute(
|
|
128
|
+
"SELECT seal_id, superstep, aether, frontier, timestamp, "
|
|
129
|
+
"metadata FROM seals WHERE invocation_id = %s "
|
|
130
|
+
"ORDER BY id DESC LIMIT 1",
|
|
131
|
+
(invocation_id,),
|
|
132
|
+
)
|
|
133
|
+
row = await cursor.fetchone()
|
|
134
|
+
finally:
|
|
135
|
+
await connection.close()
|
|
136
|
+
return _row_to_seal(row) if row is not None else None
|
|
137
|
+
|
|
138
|
+
async def list(self, invocation_id: str) -> list[Seal]:
|
|
139
|
+
"""Return the Invocation's full Seal history, oldest first."""
|
|
140
|
+
connection = await self._connect()
|
|
141
|
+
try:
|
|
142
|
+
cursor = await connection.execute(
|
|
143
|
+
"SELECT seal_id, superstep, aether, frontier, timestamp, "
|
|
144
|
+
"metadata FROM seals WHERE invocation_id = %s "
|
|
145
|
+
"ORDER BY id ASC",
|
|
146
|
+
(invocation_id,),
|
|
147
|
+
)
|
|
148
|
+
rows = await cursor.fetchall()
|
|
149
|
+
finally:
|
|
150
|
+
await connection.close()
|
|
151
|
+
return [_row_to_seal(row) for row in rows]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _row_to_seal(row: tuple[Any, ...]) -> Seal:
|
|
155
|
+
"""Rehydrate a Seal from a seals-table row."""
|
|
156
|
+
seal_id, superstep, aether, frontier, timestamp, metadata = row
|
|
157
|
+
return Seal(
|
|
158
|
+
aether=aether if isinstance(aether, dict) else json.loads(aether),
|
|
159
|
+
frontier=frontier if isinstance(frontier, list) else json.loads(frontier),
|
|
160
|
+
superstep=superstep,
|
|
161
|
+
timestamp=timestamp,
|
|
162
|
+
metadata=metadata if isinstance(metadata, dict) else json.loads(metadata),
|
|
163
|
+
seal_id=seal_id,
|
|
164
|
+
)
|