sakrit 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sakrit/__about__.py ADDED
@@ -0,0 +1,5 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # 0.0.1 is the placeholder release that claims the PyPI name (roadmap: claim the
3
+ # name independent of launch date). The real launch ships 0.1.0 with the three
4
+ # stability promises in STABILITY.md in force.
5
+ __version__ = "0.0.1"
sakrit/__init__.py ADDED
@@ -0,0 +1,42 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Sakrit — exactly-once effects for AI agents.
3
+
4
+ A thin, framework-agnostic layer that sits between an AI agent and the tools it
5
+ calls, guaranteeing that every action with real-world consequences happens
6
+ exactly once — even across crashes, resumes, retries, and parallel plans.
7
+
8
+ Importing ``sakrit`` never imports a framework. Framework adapters live under
9
+ ``sakrit.adapters`` and are imported explicitly (e.g.
10
+ ``from sakrit.adapters.langgraph import LangGraphAdapter``).
11
+ """
12
+
13
+ from collections.abc import Callable
14
+ from typing import TypeVar
15
+
16
+ from sakrit.__about__ import __version__
17
+ from sakrit.core import EffectDecl, Metrics, SqliteLedger, current_key
18
+ from sakrit.engine import Sakrit
19
+
20
+ __all__ = [
21
+ "EffectDecl",
22
+ "Metrics",
23
+ "Sakrit",
24
+ "SqliteLedger",
25
+ "__version__",
26
+ "current_key",
27
+ "safe",
28
+ ]
29
+
30
+ _F = TypeVar("_F", bound=Callable[..., object])
31
+
32
+
33
+ def safe(fn: _F) -> _F:
34
+ """Mark a callable as *reviewed and deliberately unguarded*.
35
+
36
+ Runtime-inert — returns ``fn`` unchanged, no wrapper, no cost. Its consumer is
37
+ ``sakrit doctor`` (static scan), which suppresses findings inside a function
38
+ decorated ``@sakrit.safe``. Use it to annotate-and-move-on after reviewing a
39
+ doctor finding that is genuinely non-consequential or guarded elsewhere; the
40
+ marker records that a human decided so.
41
+ """
42
+ return fn
@@ -0,0 +1,31 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Runtime adapters — the framework-facing surface.
3
+
4
+ Each adapter sources a :class:`~sakrit.core.coordinate.Coordinate` from its
5
+ runtime and satisfies :class:`~sakrit.core.adapter.RuntimeAdapter`. Framework
6
+ imports belong *here*, never in ``sakrit.core``.
7
+
8
+ :class:`~sakrit.adapters.fake.FakeAdapter` is the in-memory reference the whole
9
+ core test suite runs against — see the FakeAdapter rule in ``docs/design.md`` §11.
10
+
11
+ Shipped adapters (each behind its extra, each with a conformance gate):
12
+
13
+ - ``sakrit.adapters.langgraph`` — ``call_site = checkpoint_ns`` (interrupt/resume).
14
+ - ``sakrit.adapters.openai_agents`` — ``call_site = tool_call_id`` (RunState resume).
15
+
16
+ **CrewAI: no adapter ships, deliberately.** Probed empirically at crewai 1.15.7:
17
+ an adapter is only honest if the runtime exposes a step identity that is stable
18
+ across its durable re-execution regime, and CrewAI exposes none ambiently —
19
+ ``Task.id`` is a fresh uuid per process and ``Crew.replay`` maps stored outputs
20
+ to tasks *by index*, while the Flow contextvars (``current_flow_id``) carry a
21
+ fresh per-kickoff execution id even when ``@persist`` restores state under the
22
+ app-supplied durable id. A coordinate built on either would re-key on every
23
+ restart — a guard that dedups nothing. Integrate CrewAI through the ladder
24
+ instead: a business ``key=``, or ``with sk.step(name=..., scope=<the same
25
+ durable id you give @persist>)``. Re-evaluate if CrewAI ever exposes its
26
+ persisted state id ambiently.
27
+ """
28
+
29
+ from sakrit.adapters.fake import FakeAdapter
30
+
31
+ __all__ = ["FakeAdapter"]
@@ -0,0 +1,60 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """FakeAdapter — the in-memory reference adapter.
3
+
4
+ The FakeAdapter rule (``docs/design.md`` §11): the entire core test suite must pass
5
+ against this adapter with **no framework imported**. If a core test needs
6
+ LangGraph, the seam has leaked. It is therefore three things at once — the proof of
7
+ framework-agnosticism, the hermetic test harness, and the reference the second real
8
+ adapter is written against.
9
+
10
+ It models a deterministic runtime: the test places the adapter at a logical step
11
+ via :meth:`at`, and *re-placing* it at the same step (as a replay would) yields the
12
+ same coordinate — so the core's dedup can be exercised without any real framework.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from sakrit.core.coordinate import Capabilities, Coordinate, Stability
18
+
19
+
20
+ class FakeAdapter:
21
+ """A controllable, framework-free :class:`~sakrit.core.adapter.RuntimeAdapter`."""
22
+
23
+ def __init__(self, scope: str = "fake-run") -> None:
24
+ self._scope = scope
25
+ self._current: Coordinate | None = None
26
+ self._terminal = False
27
+
28
+ # --- test controls ----------------------------------------------------
29
+ def at(self, call_site: str, *, occurrence: int = 1, plan_epoch: int = 0) -> Coordinate:
30
+ """Place the adapter at a logical step (deterministic; re-callable to model
31
+ a replay re-executing the same step)."""
32
+ self._current = Coordinate(
33
+ scope=self._scope,
34
+ call_site=call_site.encode("utf-8"),
35
+ occurrence=occurrence,
36
+ plan_epoch=plan_epoch,
37
+ )
38
+ return self._current
39
+
40
+ def clear(self) -> None:
41
+ """Simulate being outside any step (``current_coordinate`` → ``None``)."""
42
+ self._current = None
43
+
44
+ def mark_terminal(self) -> None:
45
+ self._terminal = True
46
+
47
+ # --- RuntimeAdapter (v1 stable surface) -------------------------------
48
+ def current_coordinate(self) -> Coordinate | None:
49
+ return self._current
50
+
51
+ # --- ReservedAdapter (P5-4: semantics unfixed, not yet consumed) ------
52
+ def stability_domain(self) -> Stability:
53
+ # A deterministic in-memory runtime replays exactly; no re-planning.
54
+ return Stability.REPLAY | Stability.RETRY
55
+
56
+ def capabilities(self) -> Capabilities:
57
+ return Capabilities.NONE
58
+
59
+ def scope_terminal(self, scope: str) -> bool:
60
+ return self._terminal
@@ -0,0 +1,90 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """The LangGraph adapter — ``call_site = checkpoint_ns``.
3
+
4
+ Sources the coordinate from ``get_config()`` inside a node: ``scope = thread_id``,
5
+ ``call_site = checkpoint_ns`` (the ``"<node>:<deterministic-uuid>"`` the
6
+ conformance test proved byte-stable across resume and unique per step —
7
+ ``docs/dev-notes/callsite-conformance.md``).
8
+
9
+ **Loud refusals must reach the app, not the model (Fable C-5 / A-3 class).** If you guard
10
+ inside a ``@tool`` run by a ``ToolNode``, mind ``handle_tool_errors``. On the supported
11
+ LangGraph 1.x the **default is safe** — it swallows only ``ToolInvocationError`` (the model
12
+ sent bad args) and re-raises everything else, so a :class:`~sakrit.core.errors.SakritError`
13
+ halt (``DivergentRetry`` / ``AmbiguousOutcome``) propagates out of the graph to your app
14
+ (conformance-gated). But ``handle_tool_errors=True`` (the old 0.x default, pervasive in
15
+ tutorials) turns the halt into a model-visible *"Error: … Please fix your mistakes."* — the
16
+ app never sees it and the model can mint a fresh tool call past the halt. So: **if you set
17
+ ``handle_tool_errors``, exclude ``SakritError``** — either
18
+ ``handle_tool_errors=(YourExpectedError, …)`` (a tuple that omits Sakrit's), or the callable
19
+ :func:`sakrit_handle_tool_errors`, which re-raises a ``SakritError`` and stringifies the rest.
20
+
21
+ Framework import lives here, never in ``sakrit.core``. Requires the ``langgraph``
22
+ extra (``pip install sakrit[langgraph]``); import this module explicitly so plain
23
+ ``import sakrit`` stays framework-free.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from langgraph.config import get_config
29
+
30
+ from sakrit.core.coordinate import Capabilities, Coordinate, Stability
31
+ from sakrit.core.errors import SakritError
32
+
33
+ # The LangGraph versions this adapter's call-site derivation is conformance-tested
34
+ # against (research/repro/conformance.py). Outside this range, run the conformance
35
+ # test before trusting positional keys — an ns-derivation change strands them.
36
+ SUPPORTED_LANGGRAPH = (">=1.0", "<2.0")
37
+
38
+
39
+ def sakrit_handle_tool_errors(exc: Exception) -> str:
40
+ """A ``ToolNode(handle_tool_errors=…)`` handler that lets Sakrit's loud refusals through.
41
+
42
+ LangGraph 1.x's *default* handler already re-raises a
43
+ :class:`~sakrit.core.errors.SakritError` (only ``ToolInvocationError`` is stringified), so
44
+ you need this only if you *also* want custom error text for ordinary tool errors while
45
+ keeping the halt loud (Fable C-5). It re-raises a ``SakritError`` — so ``DivergentRetry`` /
46
+ ``AmbiguousOutcome`` reaches the app instead of the model — and returns a model-visible
47
+ string for any other exception (the message a capable model may reasonably retry)."""
48
+ if isinstance(exc, SakritError):
49
+ raise exc
50
+ return f"Error: {exc}\n Please fix your mistakes."
51
+
52
+
53
+ class LangGraphAdapter:
54
+ """A :class:`~sakrit.core.adapter.RuntimeAdapter` over LangGraph's checkpoints."""
55
+
56
+ def current_coordinate(self) -> Coordinate | None:
57
+ try:
58
+ conf = get_config().get("configurable", {})
59
+ except RuntimeError:
60
+ # Not inside a runnable context → fall to the coordinate ladder.
61
+ return None
62
+ ns = conf.get("checkpoint_ns")
63
+ thread = conf.get("thread_id")
64
+ if ns is None or thread is None:
65
+ return None
66
+ # A blank checkpoint_ns (or thread_id) cannot carry positional identity: an empty
67
+ # call_site is not unique per step, so *every* guarded call in the thread would
68
+ # collide onto one coordinate (scope + b"") — a silent swallow or DivergentRetry
69
+ # distinguished only by tool name (P4-3). Refuse it and fall to the ladder (an
70
+ # explicit key=, or a loud NoCoordinateError) rather than manufacture a colliding
71
+ # coordinate. No supported LangGraph (>=1.0,<2.0) emits "" inside a node — it is
72
+ # always "<node>:<uuid>", conformance-gated — but the guard is fail-safe if a version
73
+ # ever changes that (the signal to re-run the conformance suite before adopting it).
74
+ if not str(ns).strip() or not str(thread).strip():
75
+ return None
76
+ return Coordinate(scope=str(thread), call_site=str(ns).encode("utf-8"))
77
+
78
+ # --- ReservedAdapter (P5-4: semantics unfixed, not yet consumed). on_recovery was
79
+ # removed — the engine owns recovery (Sakrit.guard runs it once before the first claim),
80
+ # so "the adapter decides when recovery runs" was already false.
81
+ def stability_domain(self) -> Stability:
82
+ # Resume replays the recorded path; a fresh invocation that re-plans is R3
83
+ # (epochs, deferred). Within-run resume/retry is REPLAY|RETRY.
84
+ return Stability.REPLAY | Stability.RETRY
85
+
86
+ def capabilities(self) -> Capabilities:
87
+ return Capabilities.NONE
88
+
89
+ def scope_terminal(self, scope: str) -> bool:
90
+ return False
@@ -0,0 +1,170 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """The OpenAI Agents SDK adapter — ``call_site = tool_call_id``, ``scope`` explicit.
3
+
4
+ **Identity source (conformance-probed at openai-agents 0.18):** the model records each
5
+ tool call with a ``tool_call_id`` *before* it executes; the SDK's durable interruption/
6
+ resume regime (``RunState.to_string``/``from_string``, the HITL approval flow) re-dispatches
7
+ the **recorded** call — same ``tool_call_id`` — on every resume of a saved state. Re-resuming
8
+ a stale state (crash after resume, double approval) therefore re-executes the step at the
9
+ *same* ``call_site``, which is what positional dedup needs. Distinct calls get distinct
10
+ model-issued ids, so call sites are unique per step; a deliberate model repeat is a *new*
11
+ call id, so it never collides.
12
+
13
+ **Scope is explicit — and must be (Fable A-1/A-2).** The ``call_site`` is state-carried, but
14
+ the SDK exposes **no state-carried retry domain at tool time**: ``ToolContext`` carries the
15
+ call id, ``run_config`` and the user context — not the ``RunState``'s serialized trace or
16
+ conversation id. The earlier build sourced scope from the *ambient* ``get_current_trace()``,
17
+ which is controlled by the re-execution environment, not the recorded state: an app that
18
+ resumes inside ``with trace(...)`` (the SDK's own recommended grouping) mints a fresh trace id
19
+ per resume → a fresh scope → **zero dedup while looking guarded**. That is the "identity from
20
+ ambient, not from state" category error the whole design forbids. So this adapter refuses to
21
+ fabricate a scope: you pass a **stable run identity** you already persist alongside the
22
+ ``RunState`` string::
23
+
24
+ @function_tool(failure_error_function=None) # see "Loud refusals" below
25
+ async def send_email(ctx: ToolContext, to: str) -> str:
26
+ with tool_boundary(ctx, scope=run_id): # run_id: your stable, persisted id
27
+ return sk.guard(SEND_DECL, _send, kwargs={"to": to})
28
+
29
+ Without a ``scope`` the adapter yields **no coordinate** (falls to the ladder) — so a guarded
30
+ call must then carry an explicit business ``key=`` or it refuses loudly; it never silently
31
+ dedups on a fabricated scope. ``call_site`` (the recorded call id) is only used *together*
32
+ with an explicit scope.
33
+
34
+ **Loud refusals must reach the app, not the model (Fable A-3).** ``function_tool``'s default
35
+ error handler catches a raised exception and returns *"An error occurred… try again"* as the
36
+ tool's output **to the model** — which would turn Sakrit's ``DivergentRetry`` /
37
+ ``AmbiguousOutcome`` halts into a suggestion an LLM can retry around. Guarded tools MUST let a
38
+ :class:`~sakrit.core.errors.SakritError` propagate: set ``failure_error_function=None``
39
+ (re-raise everything) or ``failure_error_function=`` :func:`sakrit_failure_error_function`
40
+ (re-raise Sakrit errors, stringify ordinary ones).
41
+
42
+ **Limit (same shape as LangGraph's plan-epoch gap):** a run *re-run from scratch* (no saved
43
+ state) re-asks the model, minting new call ids — a re-plan (R3), outside positional identity
44
+ by design. Use a business ``key=`` for effects that must dedup across re-planning.
45
+
46
+ Framework import lives here, never in ``sakrit.core``. Requires the ``openai-agents`` extra;
47
+ import this module explicitly so plain ``import sakrit`` stays framework-free.
48
+ """
49
+
50
+ from __future__ import annotations
51
+
52
+ from collections.abc import Iterator
53
+ from contextlib import contextmanager
54
+ from contextvars import ContextVar
55
+
56
+ from agents.tool_context import ToolContext
57
+
58
+ from sakrit.core.coordinate import Capabilities, Coordinate, Stability
59
+ from sakrit.core.errors import SakritError
60
+
61
+ # The SDK versions this adapter's identity derivation is conformance-tested against
62
+ # (tests/integration/test_openai_agents_conformance.py). The SDK is pre-1.0 and minors
63
+ # can break: outside the tested range, run the conformance suite before trusting
64
+ # positional keys — a call-id or RunState change strands them.
65
+ SUPPORTED_OPENAI_AGENTS = (">=0.18", "<1.0")
66
+
67
+ # The active (tool context, explicit scope) bridged from the tool body. Per-task via a
68
+ # contextvar so concurrent tool executions don't clobber each other.
69
+ _tool_ctx: ContextVar[tuple[ToolContext, str | None] | None] = ContextVar(
70
+ "sakrit_oa_tool_ctx", default=None
71
+ )
72
+
73
+
74
+ @contextmanager
75
+ def tool_boundary(ctx: ToolContext, *, scope: str | None = None) -> Iterator[None]:
76
+ """Make ``ctx`` the coordinate source for guarded calls in this block.
77
+
78
+ Call it first thing in the tool body, passing the tool's ``ToolContext`` argument and a
79
+ **stable per-run identity** as ``scope`` — an id you persist alongside the ``RunState``
80
+ string (a workflow/run id), so it is byte-identical on every resume. The adapter
81
+ deliberately does not derive scope from the ambient trace (Fable A-1): that is controlled
82
+ by the resume *environment*, not the recorded state, and varies across a ``with trace(...)``
83
+ wrapper or a tracing-config change.
84
+
85
+ **The scope must be no coarser than the provider's call-id-uniqueness domain (Fable B-2).**
86
+ ``call_site`` is the model's ``tool_call_id``; OpenAI Responses ids are globally unique, but
87
+ a chat-completions/LiteLLM-backed provider can emit deterministic ids (``call_0``) that
88
+ repeat *across runs*. A **per-run** scope keeps two runs' ``call_0`` distinct; a
89
+ *conversation*-wide scope would let them collide — so scope per run, not per conversation.
90
+
91
+ ``scope`` is **stripped**, and an explicitly-passed but blank-after-strip scope is refused
92
+ loudly (Fable B-1): a whitespace-only run identity would pool every run into one scope (a
93
+ silent duplicate — the blank-``checkpoint_ns`` analog), and an unstripped ``" run-1"`` vs
94
+ ``"run-1"`` (e.g. a run id read from a file with a trailing newline) would re-key every
95
+ resume. Omitting ``scope`` entirely is allowed and yields no coordinate — the guard must
96
+ then supply an explicit ``key=`` — never a fabricated scope. Also refuses a context with no
97
+ usable ``tool_call_id``.
98
+ """
99
+ call_id = str(getattr(ctx, "tool_call_id", "") or "").strip()
100
+ if not call_id:
101
+ raise SakritError(
102
+ "tool_boundary needs a ToolContext with a tool_call_id — got none. Pass the "
103
+ "tool's ToolContext argument (declare it as the first parameter of the tool)."
104
+ )
105
+ if scope is not None:
106
+ scope = scope.strip() # normalize a trailing-newline run id so resumes don't re-key
107
+ if not scope:
108
+ raise SakritError(
109
+ "tool_boundary got a blank scope= (whitespace only). A blank run identity would "
110
+ "pool every run into one scope — a silent duplicate. Pass a real, stable per-run "
111
+ "id, or omit scope= to fall to the ladder (an explicit key=)."
112
+ )
113
+ token = _tool_ctx.set((ctx, scope))
114
+ try:
115
+ yield
116
+ finally:
117
+ _tool_ctx.reset(token)
118
+
119
+
120
+ def sakrit_failure_error_function(ctx: object, error: Exception) -> str:
121
+ """A ``function_tool(failure_error_function=...)`` that lets Sakrit's loud refusals through.
122
+
123
+ ``function_tool`` otherwise converts a raised tool exception into model-visible text, which
124
+ would silence a :class:`~sakrit.core.errors.SakritError` halt (Fable A-3). This re-raises a
125
+ ``SakritError`` (so the halt reaches the app) while stringifying an ordinary tool error the
126
+ model may reasonably retry. (Pass ``failure_error_function=None`` instead to re-raise
127
+ *every* tool error.)
128
+
129
+ **How the halt surfaces:** the SDK wraps a re-raised non-``AgentsException`` tool error in
130
+ ``agents.exceptions.UserError`` with the original as ``__cause__``. So the app catches an
131
+ ``AgentsException`` (a ``UserError``) whose ``__cause__`` is the ``SakritError`` — loud,
132
+ operator-visible, and never fed back to the model. Catch ``UserError`` and inspect
133
+ ``__cause__`` (or walk the cause chain) to recover the ``DivergentRetry`` /
134
+ ``AmbiguousOutcome``.
135
+ """
136
+ if isinstance(error, SakritError):
137
+ raise error
138
+ return f"An error occurred while running the tool: {error}"
139
+
140
+
141
+ class OpenAIAgentsAdapter:
142
+ """A :class:`~sakrit.core.adapter.RuntimeAdapter` over the Agents SDK's recorded tool calls
143
+ (see the module docstring for the conformance basis and the explicit-scope contract)."""
144
+
145
+ def current_coordinate(self) -> Coordinate | None:
146
+ bound = _tool_ctx.get()
147
+ if bound is None:
148
+ return None # not inside a tool_boundary → fall to the coordinate ladder
149
+ ctx, scope = bound
150
+ call_id = str(ctx.tool_call_id).strip()
151
+ if not call_id:
152
+ return None # belt — tool_boundary already refuses this loudly
153
+ if not scope:
154
+ # No state-carried retry domain and no explicit one → do NOT fabricate from the
155
+ # ambient trace (A-1). Fall to the ladder: the guard must carry an explicit key=.
156
+ return None
157
+ return Coordinate(scope=scope, call_site=call_id.encode("utf-8"))
158
+
159
+ # --- ReservedAdapter (P5-4: semantics unfixed, not yet consumed) ------------------
160
+ def stability_domain(self) -> Stability:
161
+ # Resuming a saved RunState re-dispatches the recorded calls; a from-scratch re-run
162
+ # re-plans (R3, epochs — deferred). Within the saved-state regime the call id survives
163
+ # resume and retry; scope is app-supplied and equally stable by contract.
164
+ return Stability.REPLAY | Stability.RETRY
165
+
166
+ def capabilities(self) -> Capabilities:
167
+ return Capabilities.NONE
168
+
169
+ def scope_terminal(self, scope: str) -> bool:
170
+ return False