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 +5 -0
- sakrit/__init__.py +42 -0
- sakrit/adapters/__init__.py +31 -0
- sakrit/adapters/fake.py +60 -0
- sakrit/adapters/langgraph.py +90 -0
- sakrit/adapters/openai_agents.py +170 -0
- sakrit/audit.py +320 -0
- sakrit/cli.py +109 -0
- sakrit/core/__init__.py +69 -0
- sakrit/core/adapter.py +141 -0
- sakrit/core/canonical.py +126 -0
- sakrit/core/context.py +38 -0
- sakrit/core/coordinate.py +57 -0
- sakrit/core/declaration.py +153 -0
- sakrit/core/errors.py +84 -0
- sakrit/core/fingerprint.py +113 -0
- sakrit/core/keys.py +54 -0
- sakrit/core/leased.py +468 -0
- sakrit/core/ledger.py +1071 -0
- sakrit/core/metrics.py +77 -0
- sakrit/core/normalizers.py +132 -0
- sakrit/core/outbox.py +13 -0
- sakrit/core/protocols.py +126 -0
- sakrit/core/reconcile.py +42 -0
- sakrit/core/seams.py +25 -0
- sakrit/core/settle.py +247 -0
- sakrit/doctor.py +441 -0
- sakrit/engine.py +558 -0
- sakrit/py.typed +0 -0
- sakrit/spec/__init__.py +39 -0
- sakrit/spec/convergence.py +242 -0
- sakrit/spec/v1.py +409 -0
- sakrit-0.0.1.dist-info/METADATA +129 -0
- sakrit-0.0.1.dist-info/RECORD +37 -0
- sakrit-0.0.1.dist-info/WHEEL +4 -0
- sakrit-0.0.1.dist-info/entry_points.txt +2 -0
- sakrit-0.0.1.dist-info/licenses/LICENSE +201 -0
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"]
|
sakrit/adapters/fake.py
ADDED
|
@@ -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
|