API reference
The grimoire's index — generated from the docstrings themselves.
Engine
The Ritual is drawn, sealed, and performed as a Rite.
Graph builder and executable plan. A Ritual registers Sigils (nodes),
static edges — several per source, enabling fan-out — and conditional
edges (routers); compile() validates the graph and returns a Rite.
Execution follows the BSP superstep model implemented in
sanctum.ritual.scheduler: all frontier Sigils run concurrently, their
deltas merge deterministically (through each Conduit's reducer when the
Ritual has an AetherSchema, by overwrite otherwise), and edges decide the
next frontier. Cycles are allowed and are what enables agentic behavior
(think -> act -> observe -> ...), bounded by recursion_limit. With a
Codex, every superstep leaves a Seal (resumption, interrupt(),
time-travel); astream yields Omens live. Fan-in is "any" by default
— add_sigil(..., join="all") turns a Sigil into a barrier over its
static predecessors. Subgraphs (Circles) arrive in a later phase.
Ritual
The circle where Sigils are bound before the invocation.
Mutable graph builder. Register nodes with add_sigil, connect them
with add_edge/add_conditional_edge (or set_entry_point for
a START edge), then call compile() to validate the graph and obtain
an executable Rite. Builder methods return self to allow chaining.
When an AetherSchema is given, every delta key must name a declared Conduit and merges through that Conduit's reducer; without a schema, deltas merge by plain overwrite. Within a superstep, deltas are applied in Sigil insertion order — the order Sigils were bound to the Ritual — so concurrent writes to the same Conduit are deterministic.
A source with several static edges fans out: all targets activate in
the next superstep. Fan-in uses "any" semantics by default: a Sigil
runs as soon as any predecessor activates it, and multiple activations
within one superstep coalesce into a single execution. Binding a Sigil
with join="all" makes it a barrier instead: it waits — across
supersteps when branches have uneven lengths — until every static
predecessor has activated it, then runs once.
add_sigil(name, fn, policy=None, *, join='any')
Bind a Sigil to the Ritual.
Registers a node in the graph. fn receives the full Aether (state
dict) and must return a partial delta to be merged into the state;
it may be sync or async. policy grants the Sigil its resilience —
timeout, retries with backoff, on_error fallback (see
SigilPolicy); it overrides compile(default_policy=...).
join sets the fan-in semantics: "any" (default) runs the
Sigil as soon as any predecessor activates it; "all" makes it a
barrier that waits for every static predecessor — activations
accumulate across supersteps (and survive Seals), and the Sigil
runs once when the last predecessor arrives. A join="all"
Sigil needs at least one static incoming edge and cannot be the
target of a conditional edge or an on_error fallback (checked at
compile time).
Raises:
| Type | Description |
|---|---|
RitualValidationError
|
If |
add_edge(source, target)
Trace a fixed path from one Sigil to the next.
Registers a static edge: after source completes, target
activates in the next superstep. source may be START and target
may be END; cycles (edges back to earlier Sigils) are allowed. A
source may carry several static edges — all targets activate
together (fan-out) — but static edges and a conditional edge are
mutually exclusive on the same source.
Raises:
| Type | Description |
|---|---|
RitualValidationError
|
If the edge starts at END, ends at
START, duplicates an existing edge, or |
add_conditional_edge(source, router, path_map=None)
Trace a branching path decided at invocation time.
Registers a conditional edge: after source completes, router
receives the full Aether and returns the name of the next Sigil (or
END). When path_map is given, the router's return value is used
as a key into it and the mapped name is followed instead. Routing a
Sigil back to an earlier one is how cycles — and agentic loops —
are built.
Raises:
| Type | Description |
|---|---|
RitualValidationError
|
If |
set_entry_point(name)
Mark a Sigil where the invocation begins.
Equivalent to add_edge(START, name). May be called several
times: all entry points activate in the first superstep (fan-out
from START).
compile(recursion_limit=DEFAULT_RECURSION_LIMIT, codex=None, default_policy=None, wards=None)
Seal the Ritual into a Rite.
Validates the graph and returns the executable plan. Checks, in
order: an entry point exists, every static edge and every
path_map target references a bound Sigil (or END), every policy's
on_error names a bound Sigil (other than its owner), every Sigil
is reachable from START (on_error fallbacks count as reachable),
and every Sigil has an outgoing edge. Cycles are valid — the graph
is a cyclic state graph, not a DAG. A conditional edge without
path_map may route anywhere, so it counts as reaching every
Sigil; its actual return values are checked at invocation time.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
recursion_limit
|
int
|
Maximum supersteps per Invocation before the Rite raises RecursionLimitError (default 25). |
DEFAULT_RECURSION_LIMIT
|
codex
|
Codex | None
|
Seal store. When given, the Rite writes a Seal at the
end of every superstep and supports resumption,
|
None
|
default_policy
|
SigilPolicy | None
|
Resilience applied to Sigils without their own policy (see SigilPolicy). |
None
|
wards
|
Sequence[Ward] | None
|
Middleware pipeline, applied in registration order — each Ward's output delta is the next one's input (see sanctum.wards). |
None
|
Raises:
| Type | Description |
|---|---|
RitualValidationError
|
On the first violation found, with a message naming the offending Sigils. |
Rite
The sealed plan of the invocation, ready to be performed.
Immutable executable graph produced by Ritual.compile(). Delegates
execution to the BSP Scheduler: every frontier Sigil runs concurrently
within a superstep, deltas merge deterministically in Sigil insertion
order (through Conduit reducers when the Rite has an AetherSchema, by
overwrite otherwise), and edges decide the next frontier until END or
recursion_limit. With a Codex attached, every superstep leaves a
Seal, enabling resumption after interrupt() and time-travel from
any historic Seal.
ainvoke(input=None, *, invocation_id=None, seal_id=None, updates=None)
async
Perform the invocation, fresh or resumed from a Seal.
Fresh run — input given, no seal_id: runs the superstep loop
with a shallow copy of input as the initial Aether. Each active
Sigil receives a copy of the full Aether (mutating it has no
effect), deltas merge deterministically, and edges compute the next
frontier. Returns the final Aether when the frontier empties or
holds only END.
Resumption — input omitted (requires a Codex and
invocation_id): restores the Invocation's latest Seal — or the
Seal named by seal_id for time-travel — and continues from its
frontier and superstep count. updates optionally injects new data
into the restored Aether before continuing (human-in-the-loop after
interrupt()); with a schema it merges through the Conduit
reducers. Resumption appends new Seals to the same history.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
input
|
Aether | None
|
Initial Aether values for a fresh run; omit to resume. |
None
|
invocation_id
|
str | None
|
Identifier of this execution session; generated (uuid4 hex) when omitted on a fresh run. |
None
|
seal_id
|
str | None
|
Historic Seal to time-travel from (implies resumption). |
None
|
updates
|
Mapping[str, Any] | None
|
Delta injected into the restored Aether on resumption. |
None
|
Raises:
| Type | Description |
|---|---|
AetherValidationError
|
If the input, a delta, or |
Interrupt
|
If a Sigil pauses the Invocation via |
RecursionLimitError
|
If the Invocation exceeds the Rite's
|
SealError
|
If resumption lacks a Codex or |
SigilExecutionError
|
If a Sigil raises during a superstep. |
TypeError
|
If a Sigil returns something other than a mapping. |
ValueError
|
If a router returns a value that is neither a bound
Sigil nor END, or is missing from its |
astream(input=None, *, invocation_id=None, seal_id=None, updates=None, mode='updates')
async
Perform the invocation, streaming Omens as it unfolds.
Async generator over the same fresh/resume semantics as
ainvoke (input, invocation_id, seal_id, updates). The
scheduler runs as a background task; Omens flow through a queue and
are yielded as they happen, filtered by mode:
"updates"(default): SigilCompleted per finished Sigil, with its delta."values": SuperstepCompleted with the full Aether after each superstep."omens": granular lifecycle — RiteBegan, SuperstepBegan, SigilBegan, SigilCompleted, SealWritten, RiteManifested."tokens": TokenEmitted payloads pushed by Sigils through their injectedwriter, delivered while the Sigil still runs.
Modes combine: mode={"updates", "tokens"} yields the union.
Exceptions from the Invocation (Interrupt, SigilExecutionError,
RecursionLimitError, and everything else ainvoke raises)
propagate to the consumer after the already-emitted Omens are
drained. Closing the generator early cancels the Invocation.
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
invoke(input=None, *, invocation_id=None, seal_id=None, updates=None)
Perform the invocation synchronously.
Convenience wrapper around ainvoke for non-async callers — same
fresh/resume semantics. Must not be called from within a running
event loop.
Wards of endurance: what to do when a Sigil falters.
Per-Sigil resilience policies. A local model can stall, a Spell can flake — the engine must not hang or die for it. A SigilPolicy bounds each attempt with a timeout, retries transient failures with exponential backoff and jitter, and can divert a definitive failure to a fallback Sigil instead of failing the Invocation.
Precedence on failure (documented contract):
- timeout bounds every attempt — an attempt past it raises SigilTimeoutError (a SigilExecutionError subclass);
- retries re-run the Sigil when the failure matches
retry_on(timeouts included by default), emitting a SigilRetried Omen per attempt and sleepingbackoff(attempt)seconds between them; - on_error — once retries are exhausted, jump to the named fallback
Sigil: the failed superstep is aborted (no Seal written, sibling
deltas discarded), the failure is appended to the reserved
"__errors__"Conduit, and the fallback runs as the next superstep (which writes its Seal normally); - otherwise the failure propagates as SigilExecutionError.
Timeouts bound async Sigils; a synchronous Sigil body cannot be
interrupted mid-run — offload blocking work with asyncio.to_thread.
BackoffFn = Callable[[int], float]
module-attribute
Delay in seconds before retry number attempt (1-based).
SigilPolicy
dataclass
The endurance granted to one Sigil.
timeout bounds each attempt in seconds (None = unbounded);
retries is how many extra attempts follow a matching failure;
backoff maps the retry number to a delay in seconds (default:
exponential with jitter, base 0.1s, cap 5s); retry_on is the tuple
of exception types worth retrying (default: any Exception — includes
SigilTimeoutError); on_error names the fallback Sigil to jump to
after the last attempt fails (see the module docstring for the full
precedence and Seal interaction).
Attach per Sigil with add_sigil(name, fn, policy=...) or globally
with compile(default_policy=...); the per-Sigil policy wins.
exponential_backoff(base=0.1, cap=5.0, jitter=True)
Build the classic exponential-backoff-with-jitter delay function.
Retry attempt waits min(cap, base * 2**(attempt-1)) seconds,
scaled by a uniform factor in [0.5, 1.0] when jitter is on (jitter
decorrelates retry storms when several Sigils fail together).
Halting the ritual so the outside world may speak.
Human-in-the-loop control flow. A Sigil calls interrupt() to pause the
Invocation: the current superstep is aborted (its deltas discarded), the
scheduler writes a Seal when a Codex is attached, and the Interrupt signal
propagates to the caller. The caller resumes later with
rite.ainvoke(invocation_id=...), optionally injecting new data into
the Aether via updates; the interrupted superstep's frontier re-executes
in full.
Interrupt
Bases: Exception
The ritual paused, awaiting words from beyond the circle.
Control-flow signal, not a failure: raised by interrupt() inside a
Sigil and propagated to the caller after a Seal is written. Carries the
caller-facing reason and the name of the Sigil that paused (sigil,
filled in by the scheduler).
interrupt(reason='')
Pause the Invocation from inside a Sigil, leaving a Seal behind.
Raises Interrupt, which aborts the current superstep (its deltas are
discarded), persists a Seal when the Rite has a Codex, and surfaces to
the caller of ainvoke/invoke. On resumption the whole
interrupted frontier — including the Sigil that paused — runs again,
so interrupting Sigils should check the Aether to decide whether the
awaited data has arrived.
The ways a ritual may be refused or go wrong while performed.
Exceptions raised by the Ritual layer.
RitualValidationError
Bases: Exception
The Ritual is malformed and cannot be sealed into a Rite.
Raised while building or compiling a Ritual whose graph is invalid: missing entry point, edges referencing unknown Sigils, Sigils unreachable from START, or Sigils without an outgoing edge.
RecursionLimitError
Bases: Exception
The ritual spun past its allotted supersteps and was cut short.
Raised by a Rite when an Invocation exceeds the Rite's
recursion_limit supersteps without reaching END — typically a cycle
whose stop condition never triggers. The message includes the
invocation_id and the last Sigil executed.
SigilExecutionError
Bases: Exception
A Sigil failed mid-ritual and the superstep was abandoned.
Raised when a Sigil raises during a superstep: the sibling Sigils of
that superstep are cancelled and none of its deltas are applied.
Carries the offending Sigil's name (sigil), a snapshot of the Aether
at the moment of failure (aether), and the original exception as
__cause__.
SigilJoinError
Bases: Exception
The rite concluded while a join still awaited absent celebrants.
Raised when an Invocation's frontier empties while one or more
join="all" Sigils are still waiting for static predecessors that
will never run — typically because a router steered a feeding branch
away from the join. pending maps each waiting Sigil to the sorted
list of predecessors it is still missing.
SigilTimeoutError
Bases: SigilExecutionError
A Sigil pondered past its allotted time and was cut off.
Raised when an attempt exceeds its SigilPolicy's timeout. Subclass
of SigilExecutionError, so generic failure handling (retries with the
default retry_on, on_error fallbacks, caller except clauses)
covers timeouts too. timeout carries the configured bound in
seconds.
State
The Conduits are drawn: the shape the shared energy must take.
State schema. A Conduit declares how one key of the Aether merges deltas (its reducer); an AetherSchema is the complete set of Conduits a Ritual operates on. When a Ritual has a schema, every delta key must name a declared Conduit and is merged through that Conduit's reducer instead of plain overwrite.
Aether = dict[str, Any]
module-attribute
The shared state dict, keyed by Conduit name.
Conduit
dataclass
One channel of the Aether and the way it merges new energy.
Declares the reducer (current, update) -> new applied when a
Sigil's delta writes to this key. Defaults to overwrite. The reducer
is skipped when the key is not yet present in the Aether: the delta
value is set directly, so custom reducers never receive a missing
current value.
AetherSchema
The declared shape of the shared energy.
Maps each Aether key to its Conduit. Values in the constructor mapping
may be Conduit instances, Annotated[T, reducer] forms, or plain
types (which default to overwrite):
AetherSchema({
"messages": Conduit(reducer=append),
"score": Annotated[int, add],
"verdict": str,
})
Delta application is deterministic: within a superstep, deltas are applied in Sigil insertion order (the order Sigils were bound to the Ritual), and each key merges through its Conduit's reducer.
conduits
property
A copy of the mapping from Aether key to its Conduit.
from_class(source)
classmethod
Read the Conduits from a class's annotations.
Sugar for declaring the schema as an annotated class::
class ChantAether:
messages: Annotated[list, append]
score: int # plain annotation -> overwrite
Annotated[T, reducer] fields use the first callable metadata
item as the Conduit's reducer.
validate_input(input)
Check that the initial Aether only uses declared Conduits.
Raises:
| Type | Description |
|---|---|
AetherValidationError
|
If |
apply_delta(aether, delta, *, sigil)
Merge one Sigil's delta into the Aether through the Conduits.
Each delta key must name a declared Conduit; its value merges via
the Conduit's reducer, or is set directly when the key is not yet
present. Returns a new dict; aether is not mutated.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
aether
|
Mapping[str, Any]
|
Current state. |
required |
delta
|
Mapping[str, Any]
|
Partial update returned by the Sigil. |
required |
sigil
|
str
|
Name of the Sigil that produced the delta, for error attribution. |
required |
Raises:
| Type | Description |
|---|---|
AetherValidationError
|
If the delta writes to a key not declared in the schema; the message names the Sigil. |
apply_deltas(aether, deltas)
Merge one superstep's deltas into the Aether, deterministically.
Deltas are applied strictly in the order given as (sigil_name,
delta) pairs; the engine passes them in Sigil insertion order
(the order Sigils were bound to the Ritual). This makes concurrent
writes to the same Conduit within a superstep deterministic: the
Conduit's reducer folds each delta in, in that fixed order.
Returns a new dict; aether is not mutated.
How new energy folds into each Conduit of the Aether.
Built-in reducers. A reducer is any callable (current, update) -> new
that merges a Sigil's delta value into a Conduit's current value:
overwrite (the default) replaces, append concatenates lists, add sums
numbers, and merge_dict shallow-merges dicts. Any custom callable with
the same signature is a valid reducer. Reducers are only called when the
Conduit already holds a value; a delta to a missing key sets it directly.
Reducer = Callable[[Any, Any], Any]
module-attribute
A Conduit's merge function: (current, update) -> new value.
overwrite(current, update)
Replace the current value with the update (the default reducer).
append(current, update)
Concatenate the update list after the current list.
The delta value must be a list of items to append (wrap single items). Returns a new list; neither input is mutated.
add(current, update)
Sum the update into the current value (numeric accumulation).
merge_dict(current, update)
Shallow-merge the update dict over the current dict.
Keys present in both take the update's value. Returns a new dict; neither input is mutated.
Persistence
Seals — the wax impressions each superstep leaves behind.
Checkpoint model and storage contract. A Seal is the snapshot written at
the end of a superstep: the full Aether, the frontier of the next
superstep, the superstep number, a timestamp, and free-form metadata. A
Codex stores Seals per Invocation (keyed by invocation_id) and yields
them back to enable resumption, human-in-the-loop pauses, and time-travel.
Seal
dataclass
The wax impression of one superstep.
Immutable checkpoint. aether is the full state after the superstep's
deltas were applied; frontier the Sigils active in the next
superstep — resumption continues from it; superstep the 1-based
count within the Invocation; timestamp epoch seconds; metadata
free-form context (e.g. interrupt details). seal_id uniquely
identifies the Seal for time-travel.
Codex
Bases: ABC
The ledger where Seals are inscribed and consulted.
Abstract async storage contract for Seals, keyed by invocation_id.
Histories are append-only: resuming an Invocation appends new Seals
after the old ones, and get returns the most recently written Seal
(not the highest superstep number). Implementations: MemoryCodex
(ephemeral, for tests), SqliteCodex (local file), PostgresCodex
(optional extra, sanctum.codex.postgres).
put(invocation_id, seal)
abstractmethod
async
Append a Seal to the Invocation's history.
get(invocation_id)
abstractmethod
async
Return the Invocation's most recently written Seal, or None.
list(invocation_id)
abstractmethod
async
Return the Invocation's full Seal history, oldest first.
A Codex written in breath: it fades when the session ends.
In-memory Seal store, the reference implementation for tests and ephemeral invocations. Not shared across processes and not persistent.
MemoryCodex
Bases: Codex
Ephemeral in-memory ledger of Seals.
Stores histories in a plain dict keyed by invocation_id. Intended
for tests and short-lived local runs; contents vanish with the object.
put(invocation_id, seal)
async
Append a Seal to the Invocation's history.
get(invocation_id)
async
Return the Invocation's most recently written Seal, or None.
list(invocation_id)
async
Return the Invocation's full Seal history, oldest first.
A Codex bound in a single local volume: one SQLite file.
Durable Seal store on the Python stdlib sqlite3 module — no external
dependencies, local-first. The Aether, frontier, and metadata are stored
as JSON columns.
Limitation: every value in the Aether and metadata must be
JSON-serializable (str, int, float, bool, None, and lists/dicts thereof).
Non-serializable state raises SealError at put time; convert rich
objects to plain data in your Sigils, or use MemoryCodex.
The interface is async to honor the Codex contract, but operations execute synchronously inline: local SQLite calls are short-lived and this keeps the core dependency-free.
SqliteCodex
Bases: Codex
Durable single-file ledger of Seals.
Persists Seals to a SQLite database at path, creating the file and
schema on first use. Recreating the object over the same path sees the
same history. Requires JSON-serializable Aether and metadata (see the
module docstring).
put(invocation_id, seal)
async
Append a Seal to the Invocation's history.
Raises:
| Type | Description |
|---|---|
SealError
|
If the Seal's Aether, frontier, or metadata cannot be serialized to JSON. |
get(invocation_id)
async
Return the Invocation's most recently written Seal, or None.
list(invocation_id)
async
Return the Invocation's full Seal history, oldest first.
Streaming & tracing
The signs a ritual gives off as it unfolds.
Streaming event model. Every Omen is a frozen, keyword-only dataclass
stamped with an epoch timestamp at creation. The scheduler emits Omens at
each lifecycle point; Rite.astream filters them by mode:
"updates": SigilCompleted — one Omen per finished Sigil, with its delta (emitted at real completion time, so parallel Sigils appear in completion order)."values": SuperstepCompleted — the full Aether after each superstep."omens": the granular lifecycle — RiteBegan, SuperstepBegan, SigilBegan, SigilCompleted, SealWritten, RiteManifested."tokens": TokenEmitted — intermediate payloads a Sigil pushes through its injectedwriter(e.g. Oracle tokens), streamed before the Sigil finishes.
Modes combine: pass a set/list of names to receive the union.
STREAM_MODES = {'updates': (SigilCompleted,), 'values': (SuperstepCompleted,), 'tokens': (TokenEmitted,), 'omens': (RiteBegan, SuperstepBegan, SigilBegan, SigilRetried, SigilCompleted, SealWritten, RiteManifested, SpellCallRepaired, SpellCallRejected, DeltaRejected, CircleEchoed)}
module-attribute
The Omen classes each astream mode yields.
Omen
dataclass
A sign given off by the ritual, stamped with its moment.
Base class of every streaming event; timestamp is epoch seconds at
creation.
RiteBegan
dataclass
SuperstepBegan
dataclass
Bases: Omen
A superstep is about to run its frontier.
frontier lists the active Sigils in insertion order.
SigilBegan
dataclass
SigilRetried
dataclass
Bases: Omen
A Sigil faltered and is being attempted again.
Emitted before each retry granted by the Sigil's policy. attempt is
the retry number (1-based); cause is the repr of the failure that
triggered it.
SigilCompleted
dataclass
Bases: Omen
A Sigil finished and returned its partial delta.
Emitted when the Sigil returns — before the superstep's deltas are merged into the Aether.
SuperstepCompleted
dataclass
SealWritten
dataclass
TokenEmitted
dataclass
Bases: Omen
A Sigil pushed an intermediate payload through its writer.
Streams while the Sigil is still running — this is how Oracle tokens reach the consumer without waiting for the superstep to finish.
RiteManifested
dataclass
DeltaRejected
dataclass
Bases: Omen
A Ward vetoed a Sigil's delta before it reached the Aether.
Emitted when Ward.after_sigil raises WardRejection: the delta is
discarded, the superstep aborts, and the Sigil's on_error policy
applies if present. ward is the vetoing Ward's class name.
SpellCallRepaired
dataclass
Bases: Omen
A garbled spell call was mended locally before casting.
Emitted by the summon loop's repair layer when a malformed call
(broken JSON, fenced blocks, prose around the payload) was recovered
without consulting the Oracle again. detail describes the mend.
SpellCallRejected
dataclass
Bases: Omen
A spell call could not be executed; the Oracle was asked to correct.
Emitted by the summon loop's repair layer when a call is unparseable,
names an unknown Spell, or carries invalid arguments. reason is the
correction message injected into the transcript for the Oracle.
CircleEchoed
dataclass
Bases: Omen
An Omen that sounded inside a Circle, echoed to the outer stream.
Emitted by a circle(...) Sigil for every Omen its inner Rite
produces: circle is the mounted Circle's name, omen the inner
event untouched (including inner TokenEmitted payloads). Consumers
that only care about the outer graph can ignore these; observability
layers can unwrap them to trace the full nested execution.
resolve_modes(mode)
Translate astream's mode into the Omen classes to yield.
Accepts a single mode name or an iterable of names; combined modes yield the union of their Omen classes.
Raises:
| Type | Description |
|---|---|
ValueError
|
If a mode name is unknown or no mode is given. |
Reading the ritual's signs after the fact — local-first tracing.
Observability without external services. TraceRecorder is a Ward:
register it with compile(wards=[...]) (or summon(wards=[...]))
and it captures the graph manifest plus every Omen, assembling a complete
trace of one Invocation — graph, superstep timeline with per-Sigil
durations, deltas, spell calls with arguments and results, retries,
repairs, rejections, and Seals — written to a .sanctum-trace.json
file when the Rite manifests (call flush() to write earlier, e.g.
after a failure). render_trace() turns a trace file into a
self-contained HTML viewer: a single file, no server, no external
requests. Tracing is opt-in by design: without a recorder the engine
pays nothing.
TraceRecorder
Bases: Ward
The chronicler of the ritual: records everything, changes nothing.
Observes the Omen stream (on_omen) and the graph manifest
(on_compile); never touches deltas, so results are identical with
or without it. Use one recorder per Invocation. The trace is written
to path when RiteManifested arrives; after an aborted Invocation,
call flush() to persist what was captured.
on_compile(manifest)
Remember the graph this recorder is watching.
on_omen(omen)
async
Record the Omen; write the trace when the Rite manifests.
flush()
Write the trace assembled so far to path and return the path.
build()
Assemble the trace document from the recorded Omens.
render_trace(trace_path, html_path=None)
Render a trace file into a self-contained HTML viewer.
One output file, no server, no external requests: all CSS is inline
and the graph is a static SVG laid out in Python (simple hierarchical
layers from START to END; back-edges drawn dashed). Returns the path
of the written HTML (defaults to the trace path with an .html
suffix).
Oracles
The voice consulted during the invocation, and the shape of its answers.
Abstract LLM interface. An Oracle receives a message transcript (dicts
with role/content) plus the JSON schemas of the Spells it may
request, and answers with an OracleResponse: free text, zero or more
SpellCalls, and usage counters. arcana identifies the concrete model.
All implementations are local-first — the core never assumes proprietary
APIs.
Message = dict[str, Any]
module-attribute
One entry of the transcript: at least role and content.
SpellCall
dataclass
The Oracle's request to cast one Spell.
spell names the Spell, arguments matches its JSON schema, and
call_id correlates the request with the result message injected back
into the transcript.
OracleResponse
dataclass
One full answer from the Oracle.
text is the assistant's message; spell_calls the Spells it wants
cast before continuing (empty means the answer is final); usage
holds free-form counters (e.g. prompt/completion tokens).
Oracle
Bases: ABC
The abstract voice every Entity consults.
Implementations must set arcana (the concrete model identifier) and
provide both a complete-answer path (generate) and a token stream
(stream_generate). Implementations: ScriptedOracle (deterministic,
for tests), OllamaOracle (sanctum.oracle.ollama), and
TransformersOracle (sanctum.oracle.transformers).
arcana
instance-attribute
Identifier of the concrete model behind this Oracle.
generate(messages, spells=None)
abstractmethod
async
Answer the transcript in one piece.
messages is the conversation so far; spells the JSON schemas
(name, description, parameters) of the Spells the Oracle may
request via spell_calls.
stream_generate(messages, spells=None)
abstractmethod
Answer the transcript as an async stream of text chunks.
Yields incremental text; pair it with a Sigil's writer to push
tokens through astream.
An Oracle that reads from a prepared script.
Deterministic Oracle for tests: answers are handed over in order from a fixed script, never touching a real model (non-negotiable principle #4). Records every call for assertions.
ScriptedOracle
Bases: Oracle
The voice that only ever says what was written for it.
script is a sequence of OracleResponse (or plain strings, shorthand
for a text-only response) returned one per generate call, in
order. Exhausting the script raises RuntimeError — a scripted test
asked one question too many. Every call is recorded in calls as
(messages, spells) for assertions.
generate(messages, spells=None)
async
Return the script's next response, recording the call.
stream_generate(messages, spells=None)
async
Yield the script's next response word by word.
stream_response(messages, spells=None)
async
Yield the next response's text word by word, then the response.
Mirrors the tool-aware streaming capability of the production adapters so streaming code paths stay testable without a model.
Weathering the imperfect speech of small local models.
Robust spell-calling. Local 7-14B models fumble tool calls in known ways:
malformed JSON, prose mixed with the payload, single quotes, unbalanced
braces, or no native tool support at all. This module treats those as
first-class inputs, not errors: extract_json recovers objects from
messy text (used by the repair layer in sanctum.grimoire.repair), and
PromptedSpellCalling wraps any Oracle to emulate tool calling through
the prompt — schemas are injected into the system prompt with a delimited
invocation format, and the answer's text is parsed back into SpellCalls.
PromptedSpellCalling
Bases: Oracle
A voice taught to cast Spells by instruction, not by wiring.
Oracle wrapper emulating tool calling for models or endpoints without
native support. With mode="always" (default) every generate
that carries Spell schemas is rewritten: schemas go into the system
prompt (inject_spell_prompt) and the answer's text is parsed back
into SpellCalls (parse_spell_blocks). With mode="auto" native
tool calling is tried first, and the wrapper falls back — and stays —
on prompted calling the first time the endpoint rejects tools
(an OracleResponseError mentioning tools).
stream_generate passes chunks through with the prompt injected;
delimited blocks appear verbatim in the stream (parsing needs the full
text), so prefer generate inside spell-calling loops.
generate(messages, spells=None)
async
Answer the transcript, emulating tool calls when needed.
stream_generate(messages, spells=None)
async
Stream the inner Oracle with the Spell prompt injected.
extract_json(text)
Tolerantly extract one JSON object from model output.
Tries, in order: the first fenced /json block, the outermost
brace region, and the raw text. Each candidate is parsed as-is, then
with simple mends — appending missing closing braces, and swapping
single quotes for double quotes when the candidate contains no double
quotes. Returns the first dict found, or None when nothing parses.
render_spell_prompt(spells)
Write the system-prompt section teaching the delimited call format.
Lists every Spell with its JSON schema and shows the exact block the model must produce to cast one.
inject_spell_prompt(messages, spells)
Fold the Spell section into the transcript's system prompt.
Appends to the existing leading system message, or prepends a new one.
Returns a new list; messages is not mutated.
parse_spell_blocks(text)
Split a prompted answer into prose and SpellCalls.
Every <spell_call>...</spell_call> block is parsed with
extract_json; blocks that still refuse to parse become SpellCalls
flagged "__malformed_json__", so the repair layer treats prompted
failures exactly like native ones. The prose outside the blocks is
returned as the response text.
Small local models frequently ignore the delimiter format and emit
the call as bare JSON instead (observed with 0.5-3B models on
llama-server): when no blocks are present but the whole answer parses
to an object carrying a "spell" name, it is routed through the
repair layer as a malformed call — validated, executed, and surfaced
as a SpellCallRepaired Omen.
The ways the Oracle's voice can fail to arrive.
Exceptions raised by the Oracle adapters. Every message is written to be actionable: it names the endpoint involved and states the most likely fix (start the server, pull the model, raise the timeout).
OracleError
Bases: Exception
The Oracle could not be consulted.
Base class of all Oracle adapter failures.
OracleConnectionError
Bases: OracleError
No one answered at the Oracle's address.
The model server could not be reached (connection refused, DNS failure). The message names the address and how to start a server there.
OracleTimeoutError
Bases: OracleError
The Oracle stayed silent past the allotted time.
The request exceeded the configured timeout. Common with local models on first request, while weights load into memory.
OracleResponseError
Bases: OracleError
The Oracle's voice arrived, but as a refusal.
The server answered with an error status (unknown model, malformed request, server fault). The message includes the HTTP status, a body snippet, and a hint when the cause is recognizable.
Grimoire
The Grimoire's pages: Spells, the @spell inscription, and the Tome.
Tool model and registry. A Spell wraps a callable with a JSON schema
(name, description, parameters) an Oracle can reason about. Spells are
declared with the @spell decorator — schema inferred from type hints
and the docstring — or loaded from an AgentGrimoire-style directory tree
via Tome.load_from_directory. A Tome is the ordered registry of
Spells handed to an Entity.
Spell
dataclass
One inscribed tool: a callable and the schema that describes it.
parameters is a JSON Schema object describing the keyword arguments
of fn, which may be sync or async. Instances stay callable —
invoking the Spell directly calls fn unchanged.
schema()
The JSON schema handed to Oracles: name, description, parameters.
execute(arguments=None)
async
Cast the Spell with keyword arguments matching its schema.
Awaits async callables transparently and returns the raw result.
Raises:
| Type | Description |
|---|---|
SpellExecutionError
|
If the underlying callable raises; the
original exception is chained as |
Tome
The bound collection of Spells an Entity may cast.
Ordered registry keyed by Spell name (registration order is preserved
and is the order Oracles see). Build it from Spell instances, register
incrementally, or load a whole directory tree with
load_from_directory.
register(spell)
Inscribe a Spell in the Tome; returns self for chaining.
Raises:
| Type | Description |
|---|---|
ValueError
|
If a Spell with the same name is already inscribed. |
get(name)
Return the Spell inscribed under name.
Raises:
| Type | Description |
|---|---|
SpellExecutionError
|
If no such Spell exists — so the ReAct loop can surface an unknown-Spell request to the Oracle as an error message instead of crashing. |
schemas()
The JSON schemas of every Spell, in registration order.
load_from_directory(path)
classmethod
Load Spells from an AgentGrimoire-style directory tree.
Proposed manifest convention for AgentGrimoire
(github.com/zquintero246/AgentGrimoire): one folder per domain,
one subfolder per Spell, each holding a spell.json manifest
next to its implementation::
<root>/
text/ # domain folders: text/, files/, ...
word_count/
spell.json
spell.py
system/
shout/
spell.json
spell.py
spell.json fields:
name(required): the Spell's name as exposed to Oracles.entrypoint(required):"<file>.py:<function>"relative to the Spell's folder; the target may be a plain function or an@spell-decorated Spell.description(optional): falls back to the entrypoint's docstring first line.parameters(optional): JSON Schema of the arguments; falls back to the schema inferred from the entrypoint's type hints.
Raises:
| Type | Description |
|---|---|
ValueError
|
If a manifest is missing required fields or its entrypoint cannot be resolved. |
spell(fn=None, *, name=None, description=None)
Inscribe a Python function as a Spell.
Decorator, usable bare or with arguments::
@spell
def word_count(text: str) -> int:
"""Count the words in a text."""
return len(text.split())
The Spell's name defaults to the function name, the description to the
docstring's first line, and parameters to a JSON Schema inferred
from the type hints (defaults become optional properties). The
decorated object is a Spell but remains directly callable.
Mending the Oracle's imperfect spell calls before they are cast.
Repair layer, applied to EVERY spell call — native tool calling or
prompted — before execution. Three tiers: local repair (tolerant JSON
extraction via sanctum.oracle.robust.extract_json, no model round
trip), conversational repair (a correction message returned to the
Oracle through the transcript, written so a small model knows exactly
what to fix), and surrender (SpellCallParseError, raised by the
summon loop once max_repair_rounds is exhausted). Repairs and
rejections surface as SpellCallRepaired / SpellCallRejected Omens.
RepairOutcome
dataclass
What the repair layer decided about one spell call.
Exactly one of call/correction is set: call is the executable
(possibly mended) SpellCall; correction is the message to return to
the Oracle. repaired describes the local mend applied, if any;
raw preserves the original unparseable text for debugging.
repair_spell_call(call, tome)
Validate and, when possible, mend one spell call.
Checks in order: (1) arguments flagged "__malformed_json__" are
recovered with extract_json — a recovered full call object
({"spell": ..., "arguments": {...}}) replaces both name and
arguments; unrecoverable text becomes a correction with the original
preserved. (2) Unknown Spell names get a correction listing the
available Spells. (3) Arguments are checked against the Spell's JSON
schema — missing required or unexpected keys get a correction naming
them. Corrections are phrased for a small model: they state exactly
what to change.
summon — calling an Entity into the circle, ready to act.
Agent factory. Builds the canonical ReAct loop as a Rite using nothing but the public primitives — Ritual, a Conduit with the append reducer, and conditional edges — demonstrating they suffice for agentic behavior:
oracle -> (spell_calls?) -> spells -> oracle -> ... -> END
The Aether holds messages (append; dicts with role/content),
plus repair_rounds and rejected_calls for the repair layer. The
oracle Sigil sends the system role plus the transcript to the Oracle
and appends its answer (carrying spell_calls when Spells were
requested). The spells Sigil passes EVERY call through the repair
layer (sanctum.grimoire.repair) before casting: malformed JSON is
recovered locally (SpellCallRepaired Omen), while unknown Spells and
invalid arguments become correction messages back to the Oracle
(SpellCallRejected Omen). Consecutive correction-only rounds are bounded
by max_repair_rounds; past it the loop surrenders with
SpellCallParseError. Spell execution failures stay conversational: they
are injected as error messages and the loop survives. The loop ends when
an answer carries no spell_calls.
summon(oracle, tome=None, role=DEFAULT_ROLE, *, spell_calling='native', max_repair_rounds=2, recursion_limit=DEFAULT_RECURSION_LIMIT, codex=None, wards=None)
Summon an Entity: an Oracle bound to a Tome, sealed as a Rite.
Returns a compiled Rite implementing the ReAct loop described in the
module docstring. Invoke it with
rite.ainvoke({"messages": [{"role": "user", "content": ...}]});
the final Aether's messages holds the full transcript.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
oracle
|
Oracle
|
The voice consulted each turn. |
required |
tome
|
Tome | None
|
The Spells available to the Entity; None summons a spell-less conversationalist. |
None
|
role
|
str
|
System prompt prepended (not stored in the Aether) on every consultation. |
DEFAULT_ROLE
|
spell_calling
|
str
|
How Spell schemas reach the model — |
'native'
|
max_repair_rounds
|
int
|
Consecutive correction-only rounds tolerated before the loop surrenders with SpellCallParseError (default 2). A round with at least one executable call resets the counter. |
2
|
recursion_limit
|
int
|
Superstep bound forwarded to |
DEFAULT_RECURSION_LIMIT
|
codex
|
Codex | None
|
Optional Seal store forwarded to |
None
|
wards
|
Sequence[Ward] | None
|
Middleware pipeline forwarded to |
None
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Wards
The protective circles drawn around the ritual.
Middleware interface. A Ward observes and intercepts the engine's work through three optional async hooks, all no-ops by default:
before_sigil(name, aether): called before a Sigil executes.after_sigil(name, aether, delta) -> delta: called with the Sigil's delta before it merges into the Aether — return it (possibly transformed), or raise WardRejection to veto it. Transformed deltas are what Seals, Omens, and downstream Wards see.on_omen(omen): called for every Omen the engine emits, before it reaches the stream — build tracing/metrics here without touching the engine.
Wards are registered with compile(wards=[...]) and applied as a
pipeline in registration order: the delta returned by one Ward is the
input of the next.
Ward
One circle of protection: observe, transform, or veto.
Subclass and override any hook; the defaults observe nothing and pass deltas through unchanged. Hooks run inside the superstep, so keep them fast — offload heavy work.
on_compile(manifest)
Called once when the Ward is bound to a compiled graph.
manifest describes the graph: {"sigils": [names], "edges":
{source: [targets]}, "conditional_edges": {source: [targets] or
["*"] when dynamic}}. Treat it as read-only. Lets observability
Wards (e.g. TraceRecorder) know the structure they are watching.
before_sigil(name, aether)
async
Called before Sigil name executes (aether is a copy).
after_sigil(name, aether, delta)
async
Inspect or transform Sigil name's delta; return the delta.
The returned mapping replaces the Sigil's delta for everything downstream (next Wards, reducers, Seals, SigilCompleted Omens).
Raises:
| Type | Description |
|---|---|
WardRejection
|
To veto the delta (see the class docstring). |
on_omen(omen)
async
Called for every Omen before it reaches the stream.
A scribe that records every change the ritual makes.
Local audit trail: one JSON Lines entry per applied delta, no external
services. Each line is {"timestamp": epoch, "sigil": name, "delta":
{...}}; non-JSON-serializable values are stringified rather than
breaking the ritual. Place an AuditWard after a RedactWard in the
pipeline so the trail only ever contains redacted content.
AuditWard
Bases: Ward
The ledger of deltas, written line by line to a local file.
Appends one JSON object per Sigil delta to path (created on first
write). Writes are synchronous appends of small lines — adequate for
local-first auditing; use one file per Invocation for clean trails.
after_sigil(name, aether, delta)
async
Record the delta with a timestamp; pass it through unchanged.
A tally of every consultation the Oracle grants.
Token and call accounting without external services. Oracle-consulting
Sigils attach the Oracle's usage counters to the messages they append
(summon does this automatically when the OracleResponse carries
usage); this Ward accumulates them per Sigil and for the whole
Invocation. Create one UsageWard per Invocation — or call reset() —
to keep totals meaningful.
UsageWard
Bases: Ward
The tally-keeper of Oracle calls and tokens.
Scans each delta's messages for entries carrying a usage
mapping and accumulates the counters. summary() reports the totals
per Sigil and for the Invocation.
after_sigil(name, aether, delta)
async
Accumulate usage counters found in the delta; pass it through.
summary()
Report accumulated usage: total and per Sigil.
Returns {"total": {counters..., "calls": n}, "by_sigil":
{sigil: {counters...}}}.
reset()
Forget every counter (e.g. between Invocations).
A veil drawn over what must not be written down.
Redaction before persistence and observability. This Ward masks regex matches (API keys, emails, ...) in every string of the delta before it merges into the Aether — so Seals, the SigilCompleted Omens, downstream Wards (place an AuditWard after it), and the final result only ever see the masked text. It cannot redact what a Sigil already sent to an external system; it guards Sanctum's own outputs.
RedactWard
Bases: Ward
The veil over secrets: masks patterns in every delta string.
patterns is an iterable of regex strings or compiled patterns; every
match anywhere in the delta (recursing through dicts, lists, and
tuples) is replaced by mask.
after_sigil(name, aether, delta)
async
Return the delta with every pattern match masked.