openbox-sdk-python 1.0.0__tar.gz → 1.1.0__tar.gz
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.
- openbox_sdk_python-1.1.0/CHANGELOG.md +23 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/PKG-INFO +2 -2
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/openbox_core/__init__.py +1 -1
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/openbox_core/adapters/base.py +71 -13
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/openbox_core/conformance/hook_preflight.py +5 -1
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/openbox_core/contracts/results.py +130 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/openbox_core/hooks/preflight.py +15 -22
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/openbox_core/runtime.py +35 -2
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/pyproject.toml +2 -2
- openbox_sdk_python-1.1.0/tests/contracts/test_retry_plan.py +207 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/tests/runtime/test_runtime_delegation.py +61 -1
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/uv.lock +49 -52
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/.github/instructions/openbox-sdk-python.instructions.md +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/.github/workflows/ci.yml +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/.github/workflows/publish.yml +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/.gitignore +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/.python-version +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/README.md +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/openbox_core/adapters/__init__.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/openbox_core/approvals.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/openbox_core/client.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/openbox_core/config.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/openbox_core/conformance/__init__.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/openbox_core/conformance/fake_core.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/openbox_core/conformance/instrumentation.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/openbox_core/context.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/openbox_core/contracts/__init__.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/openbox_core/contracts/context.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/openbox_core/contracts/events.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/openbox_core/contracts/otel_spans.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/openbox_core/errors.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/openbox_core/gate.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/openbox_core/hooks/__init__.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/openbox_core/hooks/events.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/openbox_core/hooks/wrappers.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/openbox_core/identity.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/openbox_core/instrumentation/__init__.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/openbox_core/instrumentation/db.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/openbox_core/instrumentation/file.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/openbox_core/instrumentation/function.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/openbox_core/instrumentation/http.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/openbox_core/instrumentation/llm.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/openbox_core/instrumentation/manager.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/openbox_core/instrumentation/shared.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/openbox_core/otel/__init__.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/openbox_core/otel/propagation.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/openbox_core/otel/provider.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/openbox_core/otel/setup.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/openbox_core/otel/span_processor.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/openbox_core/otel/trace_context.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/openbox_core/py.typed +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/openbox_core/sdk_version.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/openbox_core/serialization.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/openbox_core/validation/__init__.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/openbox_core/validation/diagnostics.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/openbox_core/validation/event_rules.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/openbox_core/validation/registry.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/openbox_core/validation/span_normalization.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/openbox_core/wire/__init__.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/openbox_core/wire/core_span.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/openbox_core/wire/evaluate_payload.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/tests/client/test_approval_poll.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/tests/client/test_fail_modes.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/tests/config/test_resolution_order.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/tests/conformance/test_required_cases.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/tests/context/test_bind_reset.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/tests/context/test_trace_key.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/tests/contracts/test_approval_parsing.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/tests/contracts/test_event_classify.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/tests/contracts/test_result_parsing.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/tests/gate/test_diagnostics.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/tests/gate/test_strict_failures.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/tests/instrumentation/conftest.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/tests/instrumentation/instrumented_env.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/tests/instrumentation/test_db_block.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/tests/instrumentation/test_db_redis_mongo.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/tests/instrumentation/test_file_function_block.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/tests/instrumentation/test_hook_failclosed_and_sync_approval.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/tests/instrumentation/test_hook_runtime.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/tests/instrumentation/test_http_preflight_block.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/tests/instrumentation/test_http_urllib.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/tests/instrumentation/test_manager_and_otel_lifecycle.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/tests/signing/generate_golden_fixture_from_temporal_signer.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/tests/signing/golden_temporal_signed_request.json +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/tests/signing/test_golden_signing.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/tests/test_import_safety.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/tests/test_sdk_version.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/tests/wire/go_spandata_compat/go.mod +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/tests/wire/go_spandata_compat/main.go +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/tests/wire/span_fixtures.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/tests/wire/test_backend_compat.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/tests/wire/test_core_span_projection.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/tests/wire/test_flat_hook_contract.py +0 -0
- {openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/tests/wire/test_hex_ids.py +0 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [1.1.0] - 2026-07-21
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- `RetryPlan` and `RetryDirective` dataclasses in `openbox_core.contracts.results`.
|
|
12
|
+
- Optional `retry_plan` directive parsing on both `EvaluationResult` and `ApprovalResult`.
|
|
13
|
+
A `_MISSING` sentinel keeps a present `new_input: null` distinct from an absent field; falsy
|
|
14
|
+
values (`null`, `""`, `0`, `[]`, `{}`) are preserved; a boolean `new_input` is rejected; and every
|
|
15
|
+
number (recursively) must be finite and, if integral, a JS-safe integer (`|n| <= 2^53 - 1`).
|
|
16
|
+
- `handle_retryable_block(result)` — an opt-in, pure inspector that returns a `RetryDirective` only
|
|
17
|
+
for a `BLOCK` verdict carrying a valid plan. Returns `None` for a plain BLOCK, every non-BLOCK
|
|
18
|
+
verdict (including HALT), a pending verdict, and an expired `ApprovalResult`.
|
|
19
|
+
|
|
20
|
+
### Notes
|
|
21
|
+
- Default enforcement is unchanged: a `BLOCK` verdict still raises `GovernanceBlockedError`. The new
|
|
22
|
+
helper is opt-in and never triggers an automatic retry; malformed or ineligible plans are treated
|
|
23
|
+
as absent (never fail open).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: openbox-sdk-python
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.1.0
|
|
4
4
|
Summary: OpenBox base SDK - governance contracts, strict gate, identity/signing, evaluate client, context runtime, OTel span wire serialization, and generic instrumentation shared by every OpenBox framework SDK
|
|
5
5
|
Author-email: OpenBox Team <tino@openbox.ai>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -15,7 +15,7 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
|
15
15
|
Classifier: Topic :: System :: Monitoring
|
|
16
16
|
Classifier: Typing :: Typed
|
|
17
17
|
Requires-Python: >=3.11
|
|
18
|
-
Requires-Dist: cryptography<
|
|
18
|
+
Requires-Dist: cryptography<50,>=48.0.1
|
|
19
19
|
Requires-Dist: httpx<1,>=0.28.0
|
|
20
20
|
Requires-Dist: opentelemetry-api<1.40.0,>=1.38.0
|
|
21
21
|
Requires-Dist: opentelemetry-sdk<1.40.0,>=1.38.0
|
|
@@ -37,7 +37,7 @@ from .errors import (
|
|
|
37
37
|
# governance; eagerly it can deadlock package init as a circular import, and
|
|
38
38
|
# lazily it can recurse unboundedly when a per-request header builder resolves
|
|
39
39
|
# the version. Keep in sync with pyproject.toml on release.
|
|
40
|
-
__version__ = "1.
|
|
40
|
+
__version__ = "1.1.0"
|
|
41
41
|
|
|
42
42
|
__all__ = [
|
|
43
43
|
"__version__",
|
|
@@ -8,7 +8,8 @@ core error types directly.
|
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
-
from
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
|
|
12
13
|
|
|
13
14
|
from ..contracts.results import EvaluationResult
|
|
14
15
|
from ..errors import (
|
|
@@ -24,7 +25,7 @@ if TYPE_CHECKING:
|
|
|
24
25
|
from ..approvals import ApprovalPoller
|
|
25
26
|
from ..contracts.context import ActivityContext
|
|
26
27
|
|
|
27
|
-
__all__ = ["FrameworkAdapter", "CoreAdapter"]
|
|
28
|
+
__all__ = ["FrameworkAdapter", "CoreAdapter", "adapter_accepts_context"]
|
|
28
29
|
|
|
29
30
|
|
|
30
31
|
@runtime_checkable
|
|
@@ -33,16 +34,27 @@ class FrameworkAdapter(Protocol):
|
|
|
33
34
|
|
|
34
35
|
name: str
|
|
35
36
|
|
|
36
|
-
async def handle_approval(
|
|
37
|
+
async def handle_approval(
|
|
38
|
+
self, result: EvaluationResult, context: ActivityContext | None = None
|
|
39
|
+
) -> None:
|
|
37
40
|
"""Drive the framework's approval flow for REQUIRE_APPROVAL.
|
|
38
41
|
|
|
39
42
|
Return normally when approved; raise the framework's native rejection/
|
|
40
43
|
expiry error otherwise. Called BEFORE the real operation runs.
|
|
41
44
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
45
|
+
``context`` carries the workflow/run/activity IDs the approval poll
|
|
46
|
+
needs: Core's evaluate response does NOT echo them, so they cannot be
|
|
47
|
+
recovered from ``result.raw``. The runtime passes the originating
|
|
48
|
+
``ActivityContext`` (lifecycle events build one from the event).
|
|
49
|
+
Optional — an adapter defined as ``handle_approval(self, result)`` still
|
|
50
|
+
conforms; the runtime passes ``context`` only when the signature accepts
|
|
51
|
+
it (see :func:`adapter_accepts_context`).
|
|
52
|
+
|
|
53
|
+
Adapters may ALSO define a plain-sync ``handle_approval_sync(result,
|
|
54
|
+
context)`` (not part of the required protocol): when present, sync hook
|
|
55
|
+
paths delegate to it instead of driving the core inline poller.
|
|
56
|
+
Frameworks with retry-based HITL can raise their native pending error
|
|
57
|
+
there.
|
|
46
58
|
"""
|
|
47
59
|
...
|
|
48
60
|
|
|
@@ -82,17 +94,16 @@ class CoreAdapter:
|
|
|
82
94
|
def __init__(self, approval_poller: ApprovalPoller | None = None):
|
|
83
95
|
self._poller = approval_poller
|
|
84
96
|
|
|
85
|
-
async def handle_approval(
|
|
97
|
+
async def handle_approval(
|
|
98
|
+
self, result: EvaluationResult, context: ActivityContext | None = None
|
|
99
|
+
) -> None:
|
|
86
100
|
if self._poller is None or not result.approval_id:
|
|
87
101
|
raise ApprovalRejectedError(
|
|
88
102
|
"REQUIRE_APPROVAL verdict but no approval flow is configured — "
|
|
89
103
|
"failing safe (operation not run)"
|
|
90
104
|
)
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
result.raw.get("run_id", ""),
|
|
94
|
-
result.raw.get("activity_id", ""),
|
|
95
|
-
)
|
|
105
|
+
workflow_id, run_id, activity_id = _approval_poll_ids(result, context)
|
|
106
|
+
approval = await self._poller.await_decision(workflow_id, run_id, activity_id)
|
|
96
107
|
if approval.allow_shaped:
|
|
97
108
|
return
|
|
98
109
|
if approval.expired:
|
|
@@ -121,3 +132,50 @@ class CoreAdapter:
|
|
|
121
132
|
raise GovernanceBlockedError(
|
|
122
133
|
result.verdict, result.reason or "Blocked by governance policy"
|
|
123
134
|
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _approval_poll_ids(
|
|
138
|
+
result: EvaluationResult, context: ActivityContext | None
|
|
139
|
+
) -> tuple[str, str, str]:
|
|
140
|
+
"""Resolve the (workflow_id, run_id, activity_id) the approval poll sends.
|
|
141
|
+
|
|
142
|
+
Core's evaluate response does not echo them, so the originating
|
|
143
|
+
``ActivityContext`` is authoritative; ``result.raw`` is only a fallback for
|
|
144
|
+
a caller that predates the ``context`` argument (it is empty in real Core
|
|
145
|
+
traffic).
|
|
146
|
+
"""
|
|
147
|
+
raw = result.raw
|
|
148
|
+
if context is None:
|
|
149
|
+
return (
|
|
150
|
+
raw.get("workflow_id", ""),
|
|
151
|
+
raw.get("run_id", ""),
|
|
152
|
+
raw.get("activity_id", ""),
|
|
153
|
+
)
|
|
154
|
+
return (
|
|
155
|
+
context.workflow_id or raw.get("workflow_id", ""),
|
|
156
|
+
context.run_id or raw.get("run_id", ""),
|
|
157
|
+
context.activity_id or raw.get("activity_id", ""),
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def adapter_accepts_context(callback: Callable[..., Any] | None) -> bool:
|
|
162
|
+
"""True when an adapter callback accepts a ``context`` argument (an explicit
|
|
163
|
+
parameter or ``**kwargs``).
|
|
164
|
+
|
|
165
|
+
Checked by signature so a genuine ``TypeError`` raised inside the callback
|
|
166
|
+
body is never mistaken for an arity mismatch and silently dropped. Lets the
|
|
167
|
+
runtime stay backward-compatible with adapters written against the older
|
|
168
|
+
``handle_approval(self, result)`` / ``on_completed_hook_result(self,
|
|
169
|
+
result)`` signatures.
|
|
170
|
+
"""
|
|
171
|
+
import inspect
|
|
172
|
+
|
|
173
|
+
if callback is None:
|
|
174
|
+
return False
|
|
175
|
+
try:
|
|
176
|
+
params = inspect.signature(callback).parameters
|
|
177
|
+
except (TypeError, ValueError):
|
|
178
|
+
return False
|
|
179
|
+
return "context" in params or any(
|
|
180
|
+
p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values()
|
|
181
|
+
)
|
{openbox_sdk_python-1.0.0 → openbox_sdk_python-1.1.0}/openbox_core/conformance/hook_preflight.py
RENAMED
|
@@ -39,10 +39,14 @@ class RecordingHookAdapter:
|
|
|
39
39
|
self.completed_results: list[EvaluationResult] = []
|
|
40
40
|
self.completed_contexts: list[ActivityContext | None] = []
|
|
41
41
|
self.approvals: list[EvaluationResult] = []
|
|
42
|
+
self.approval_contexts: list[ActivityContext | None] = []
|
|
42
43
|
self.approve_next = True
|
|
43
44
|
|
|
44
|
-
async def handle_approval(
|
|
45
|
+
async def handle_approval(
|
|
46
|
+
self, result: EvaluationResult, context: ActivityContext | None = None
|
|
47
|
+
) -> None:
|
|
45
48
|
self.approvals.append(result)
|
|
49
|
+
self.approval_contexts.append(context)
|
|
46
50
|
if not self.approve_next:
|
|
47
51
|
raise ApprovalRejectedError("rejected by conformance adapter")
|
|
48
52
|
|
|
@@ -10,6 +10,7 @@ tolerant of unknown keys (field-shape drift from Core must not crash SDKs).
|
|
|
10
10
|
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
|
+
import math
|
|
13
14
|
from dataclasses import dataclass, field
|
|
14
15
|
from enum import Enum
|
|
15
16
|
from typing import Any
|
|
@@ -19,6 +20,9 @@ __all__ = [
|
|
|
19
20
|
"GuardrailsResult",
|
|
20
21
|
"EvaluationResult",
|
|
21
22
|
"ApprovalResult",
|
|
23
|
+
"RetryPlan",
|
|
24
|
+
"RetryDirective",
|
|
25
|
+
"handle_retryable_block",
|
|
22
26
|
]
|
|
23
27
|
|
|
24
28
|
|
|
@@ -102,6 +106,87 @@ class GuardrailsResult:
|
|
|
102
106
|
return [r.get("reason", "") for r in self.reasons if r.get("reason")]
|
|
103
107
|
|
|
104
108
|
|
|
109
|
+
# Sentinel distinguishing an absent ``retry_plan`` field from a present JSON ``null``.
|
|
110
|
+
# ``dict.get("retry_plan")`` would collapse both to None; membership via this sentinel does not.
|
|
111
|
+
_MISSING = object()
|
|
112
|
+
|
|
113
|
+
# JS Number.MAX_SAFE_INTEGER. Integral numbers must fit within +/- this bound so ``new_input`` can
|
|
114
|
+
# never silently change across FE, backend, Core, and Python (json.loads yields arbitrary-precision
|
|
115
|
+
# ``int`` and ``float('inf')``, both broader than JS).
|
|
116
|
+
_MAX_SAFE_INTEGER = 2**53 - 1
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _numbers_are_safe(value: Any) -> bool:
|
|
120
|
+
"""Every number (recursively) must be finite and, if integral, within the JS-safe range.
|
|
121
|
+
|
|
122
|
+
``bool`` is a subclass of ``int`` but is not a number here; a nested bool is a plain leaf
|
|
123
|
+
(top-level ``new_input`` booleans are rejected separately by :func:`_parse_retry_plan`).
|
|
124
|
+
"""
|
|
125
|
+
if isinstance(value, bool):
|
|
126
|
+
return True
|
|
127
|
+
if isinstance(value, int):
|
|
128
|
+
return abs(value) <= _MAX_SAFE_INTEGER
|
|
129
|
+
if isinstance(value, float):
|
|
130
|
+
if math.isinf(value) or math.isnan(value):
|
|
131
|
+
return False
|
|
132
|
+
if value.is_integer():
|
|
133
|
+
return abs(value) <= _MAX_SAFE_INTEGER
|
|
134
|
+
return True
|
|
135
|
+
if isinstance(value, dict):
|
|
136
|
+
return all(_numbers_are_safe(v) for v in value.values())
|
|
137
|
+
if isinstance(value, (list, tuple)):
|
|
138
|
+
return all(_numbers_are_safe(v) for v in value)
|
|
139
|
+
return True
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclass
|
|
143
|
+
class RetryPlan:
|
|
144
|
+
"""Optional remediation hint carried on a BLOCK verdict (policy-produced or admin-retry).
|
|
145
|
+
|
|
146
|
+
``new_input`` may be null | str | number | list | dict (boolean rejected). A present
|
|
147
|
+
``new_input`` of ``None`` is a valid plan (retry with the original input) and is distinct
|
|
148
|
+
from an absent ``retry_plan`` field.
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
new_input: Any = None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass
|
|
155
|
+
class RetryDirective:
|
|
156
|
+
"""Caller-facing view of a retryable BLOCK, surfaced only via :func:`handle_retryable_block`.
|
|
157
|
+
|
|
158
|
+
It is a remediation hint, not proof that a retry was evaluated or executed.
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
new_input: Any = None
|
|
162
|
+
governance_event_id: str | None = None
|
|
163
|
+
reason: str | None = None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _parse_retry_plan(container: dict[str, Any]) -> RetryPlan | None:
|
|
167
|
+
"""Parse an optional ``retry_plan`` from a result dict against the frozen wire contract.
|
|
168
|
+
|
|
169
|
+
Returns a plan only when ``retry_plan`` is present, is a dict whose ONLY key is ``new_input``,
|
|
170
|
+
and ``new_input`` is null/str/number/list/dict (boolean rejected) with every number finite and,
|
|
171
|
+
if integral, within the JS-safe integer range. An absent field, a JSON ``null`` field, or any
|
|
172
|
+
contract violation yields ``None`` (treated as absent — never an error). A present
|
|
173
|
+
``new_input`` of ``None`` is preserved as a valid plan.
|
|
174
|
+
"""
|
|
175
|
+
plan = container.get("retry_plan", _MISSING)
|
|
176
|
+
if plan is _MISSING or not isinstance(plan, dict):
|
|
177
|
+
return None
|
|
178
|
+
# Exactly the single key new_input (also guarantees new_input is present, not merely null).
|
|
179
|
+
if set(plan.keys()) != {"new_input"}:
|
|
180
|
+
return None
|
|
181
|
+
new_input = plan["new_input"]
|
|
182
|
+
# bool is a subclass of int — reject a boolean new_input BEFORE the number check.
|
|
183
|
+
if isinstance(new_input, bool):
|
|
184
|
+
return None
|
|
185
|
+
if not _numbers_are_safe(new_input):
|
|
186
|
+
return None
|
|
187
|
+
return RetryPlan(new_input=new_input)
|
|
188
|
+
|
|
189
|
+
|
|
105
190
|
@dataclass
|
|
106
191
|
class EvaluationResult:
|
|
107
192
|
"""Response from a governance evaluation.
|
|
@@ -130,6 +215,7 @@ class EvaluationResult:
|
|
|
130
215
|
fallback_used: bool = False # True when fail-open produced this result
|
|
131
216
|
diagnostics: list[Any] = field(default_factory=list)
|
|
132
217
|
raw: dict[str, Any] = field(default_factory=dict)
|
|
218
|
+
retry_plan: RetryPlan | None = None # Optional BLOCK remediation hint; None = absent
|
|
133
219
|
|
|
134
220
|
@property
|
|
135
221
|
def guardrails_result(self) -> GuardrailsResult | None:
|
|
@@ -180,6 +266,7 @@ class EvaluationResult:
|
|
|
180
266
|
fallback_used=bool(data.get("fallback_used", False)),
|
|
181
267
|
diagnostics=data.get("diagnostics") or [],
|
|
182
268
|
raw=dict(data),
|
|
269
|
+
retry_plan=_parse_retry_plan(data),
|
|
183
270
|
)
|
|
184
271
|
|
|
185
272
|
@classmethod
|
|
@@ -212,6 +299,7 @@ class ApprovalResult:
|
|
|
212
299
|
approval_expiration_time: str | None = None
|
|
213
300
|
expired: bool = False
|
|
214
301
|
raw: dict[str, Any] = field(default_factory=dict)
|
|
302
|
+
retry_plan: RetryPlan | None = None # Present only on an admin-retry BLOCK
|
|
215
303
|
|
|
216
304
|
# Known decision vocabulary for approvals (current values + accepted aliases).
|
|
217
305
|
# Anything OUTSIDE this set parses to None (pending) — the evaluate-path
|
|
@@ -257,6 +345,7 @@ class ApprovalResult:
|
|
|
257
345
|
approval_expiration_time=data.get("approval_expiration_time"),
|
|
258
346
|
expired=bool(data.get("expired", False)),
|
|
259
347
|
raw=dict(data),
|
|
348
|
+
retry_plan=_parse_retry_plan(data),
|
|
260
349
|
)
|
|
261
350
|
|
|
262
351
|
@property
|
|
@@ -285,3 +374,44 @@ class ApprovalResult:
|
|
|
285
374
|
if self.verdict is None:
|
|
286
375
|
return True
|
|
287
376
|
return self.verdict in (Verdict.REQUIRE_APPROVAL, Verdict.CONSTRAIN)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def handle_retryable_block(
|
|
380
|
+
result: EvaluationResult | ApprovalResult,
|
|
381
|
+
) -> RetryDirective | None:
|
|
382
|
+
"""Opt-in, pure inspector: surface a retry directive from a retryable BLOCK.
|
|
383
|
+
|
|
384
|
+
Returns a :class:`RetryDirective` ONLY for ``verdict == Verdict.BLOCK`` with a present, valid
|
|
385
|
+
``retry_plan``. Returns ``None`` for plain BLOCK, every non-BLOCK verdict (including HALT),
|
|
386
|
+
a pending/``None`` verdict, and an expired :class:`ApprovalResult`.
|
|
387
|
+
|
|
388
|
+
This does NOT change enforcement: importing or exposing it never auto-retries. A caller must
|
|
389
|
+
invoke it explicitly, and it makes no claim that a replacement was evaluated or executed.
|
|
390
|
+
|
|
391
|
+
The gate is deliberately ``verdict == Verdict.BLOCK`` — NOT ``is_blocking()`` /
|
|
392
|
+
``verdict.should_stop()``, which also match HALT (and, for approvals, expired results) and would
|
|
393
|
+
violate the wire-contract invariant that HALT always strips the plan.
|
|
394
|
+
"""
|
|
395
|
+
# A stale/expired poll result must not surface a directive, even if verdict == BLOCK.
|
|
396
|
+
if isinstance(result, ApprovalResult) and result.expired:
|
|
397
|
+
return None
|
|
398
|
+
# ApprovalResult.verdict may be None (pending); the != BLOCK comparison handles that. Using
|
|
399
|
+
# getattr keeps this safe if a caller passes an unexpected object.
|
|
400
|
+
if getattr(result, "verdict", None) != Verdict.BLOCK:
|
|
401
|
+
return None
|
|
402
|
+
|
|
403
|
+
plan = result.retry_plan
|
|
404
|
+
if plan is None:
|
|
405
|
+
return None
|
|
406
|
+
|
|
407
|
+
# governance_event_id is typed on EvaluationResult; ApprovalResult carries it only in raw.
|
|
408
|
+
if isinstance(result, EvaluationResult):
|
|
409
|
+
governance_event_id = result.governance_event_id
|
|
410
|
+
else:
|
|
411
|
+
governance_event_id = result.raw.get("governance_event_id") or result.raw.get("id")
|
|
412
|
+
|
|
413
|
+
return RetryDirective(
|
|
414
|
+
new_input=plan.new_input,
|
|
415
|
+
governance_event_id=governance_event_id,
|
|
416
|
+
reason=result.reason,
|
|
417
|
+
)
|
|
@@ -18,6 +18,7 @@ import logging
|
|
|
18
18
|
from collections.abc import Mapping
|
|
19
19
|
from typing import Any, NoReturn
|
|
20
20
|
|
|
21
|
+
from ..adapters.base import adapter_accepts_context
|
|
21
22
|
from ..approvals import ApprovalPoller
|
|
22
23
|
from ..contracts.events import EventEnvelope
|
|
23
24
|
from ..contracts.otel_spans import HookType, Stage
|
|
@@ -39,10 +40,13 @@ class HookRuntime:
|
|
|
39
40
|
self._store = runtime.context_store
|
|
40
41
|
self._gate = runtime.gate
|
|
41
42
|
self._adapter = runtime.adapter
|
|
42
|
-
# Decide ONCE whether the adapter's
|
|
43
|
-
#
|
|
43
|
+
# Decide ONCE whether the adapter's callbacks take ``context``, by
|
|
44
|
+
# inspecting their signatures — so a genuine TypeError raised inside a
|
|
44
45
|
# callback body is never mistaken for an arity mismatch and swallowed.
|
|
45
|
-
self._completed_accepts_context =
|
|
46
|
+
self._completed_accepts_context = adapter_accepts_context(
|
|
47
|
+
self._adapter.on_completed_hook_result
|
|
48
|
+
)
|
|
49
|
+
self._approval_accepts_context = adapter_accepts_context(self._adapter.handle_approval)
|
|
46
50
|
hitl = runtime.config.hitl
|
|
47
51
|
self._sync_poller: ApprovalPoller | None = None
|
|
48
52
|
if hitl.enabled:
|
|
@@ -163,7 +167,14 @@ class HookRuntime:
|
|
|
163
167
|
)
|
|
164
168
|
if verdict.requires_approval():
|
|
165
169
|
# Adapter drives its native approval flow; returning ⇒ approved.
|
|
166
|
-
|
|
170
|
+
# Core omits the workflow/run/activity IDs from the evaluate
|
|
171
|
+
# response, so hand the span-resolved context for the poll.
|
|
172
|
+
if self._approval_accepts_context:
|
|
173
|
+
await self._adapter.handle_approval(
|
|
174
|
+
result, context=resolve_context(self._store, span)
|
|
175
|
+
)
|
|
176
|
+
else:
|
|
177
|
+
await self._adapter.handle_approval(result)
|
|
167
178
|
return True
|
|
168
179
|
return True
|
|
169
180
|
|
|
@@ -259,24 +270,6 @@ class HookRuntime:
|
|
|
259
270
|
return
|
|
260
271
|
self._after_completed(result, span)
|
|
261
272
|
|
|
262
|
-
def _adapter_accepts_context(self) -> bool:
|
|
263
|
-
"""True when the adapter's ``on_completed_hook_result`` accepts a
|
|
264
|
-
``context`` argument (checked once, by signature — not by catching a
|
|
265
|
-
TypeError from the call, which would mask real errors)."""
|
|
266
|
-
import inspect
|
|
267
|
-
|
|
268
|
-
callback = getattr(self._adapter, "on_completed_hook_result", None)
|
|
269
|
-
if callback is None:
|
|
270
|
-
return False
|
|
271
|
-
try:
|
|
272
|
-
params = inspect.signature(callback).parameters
|
|
273
|
-
except (TypeError, ValueError):
|
|
274
|
-
return False
|
|
275
|
-
# Accepts context via an explicit param or **kwargs.
|
|
276
|
-
return "context" in params or any(
|
|
277
|
-
p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values()
|
|
278
|
-
)
|
|
279
|
-
|
|
280
273
|
def _after_completed(self, result: EvaluationResult, span: Any) -> None:
|
|
281
274
|
if result.verdict.should_stop():
|
|
282
275
|
self._mark_stopped(result, span) # future execution only
|
|
@@ -17,10 +17,11 @@ from __future__ import annotations
|
|
|
17
17
|
|
|
18
18
|
from typing import Any
|
|
19
19
|
|
|
20
|
-
from .adapters.base import CoreAdapter, FrameworkAdapter
|
|
20
|
+
from .adapters.base import CoreAdapter, FrameworkAdapter, adapter_accepts_context
|
|
21
21
|
from .client import EvaluationClient
|
|
22
22
|
from .config import OpenBoxConfig
|
|
23
23
|
from .context import ContextStore, default_context_store
|
|
24
|
+
from .contracts.context import ActivityContext
|
|
24
25
|
from .contracts.events import EventEnvelope
|
|
25
26
|
from .contracts.results import EvaluationResult, Verdict
|
|
26
27
|
from .errors import GuardrailsValidationError
|
|
@@ -51,6 +52,9 @@ class OpenBoxRuntime:
|
|
|
51
52
|
):
|
|
52
53
|
self.config = config
|
|
53
54
|
self.adapter: FrameworkAdapter = adapter if adapter is not None else CoreAdapter()
|
|
55
|
+
# Decide ONCE whether the adapter's handle_approval accepts ``context``
|
|
56
|
+
# (older adapters take only ``result``) — see adapter_accepts_context.
|
|
57
|
+
self._approval_accepts_context = adapter_accepts_context(self.adapter.handle_approval)
|
|
54
58
|
self.context_store = context_store if context_store is not None else default_context_store()
|
|
55
59
|
self.client = client if client is not None else EvaluationClient(
|
|
56
60
|
config.api_url,
|
|
@@ -104,7 +108,14 @@ class OpenBoxRuntime:
|
|
|
104
108
|
result = await self.gate.aevaluate(event)
|
|
105
109
|
if result.verdict.requires_approval():
|
|
106
110
|
self._check_guardrails(result)
|
|
107
|
-
|
|
111
|
+
# Core omits workflow/run/activity IDs from the evaluate response, so
|
|
112
|
+
# build the approval context from the originating event for the poll.
|
|
113
|
+
if self._approval_accepts_context:
|
|
114
|
+
await self.adapter.handle_approval(
|
|
115
|
+
result, context=_approval_context_from_event(event)
|
|
116
|
+
)
|
|
117
|
+
else:
|
|
118
|
+
await self.adapter.handle_approval(result)
|
|
108
119
|
return result
|
|
109
120
|
return self._enforce_lifecycle(result, drive_approval=False)
|
|
110
121
|
|
|
@@ -136,3 +147,25 @@ class OpenBoxRuntime:
|
|
|
136
147
|
self.uninstall_instrumentation()
|
|
137
148
|
self.context_store.clear()
|
|
138
149
|
await self.client.aclose()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _approval_context_from_event(event: EventEnvelope) -> ActivityContext:
|
|
153
|
+
"""Build the approval context for a lifecycle event.
|
|
154
|
+
|
|
155
|
+
``workflow_id`` / ``run_id`` live in the flat wire ``payload``;
|
|
156
|
+
``activity_id`` is a first-class envelope field (a workflow-level approval
|
|
157
|
+
legitimately has none). Core's evaluate response omits all three, so the
|
|
158
|
+
poll must be built from the originating event — see
|
|
159
|
+
``CoreAdapter.handle_approval``.
|
|
160
|
+
"""
|
|
161
|
+
payload = event.payload
|
|
162
|
+
|
|
163
|
+
def _s(value: Any) -> str | None:
|
|
164
|
+
return value if isinstance(value, str) else None
|
|
165
|
+
|
|
166
|
+
activity_id = event.activity_id if event.activity_id is not None else _s(payload.get("activity_id"))
|
|
167
|
+
return ActivityContext(
|
|
168
|
+
workflow_id=_s(payload.get("workflow_id")),
|
|
169
|
+
run_id=_s(payload.get("run_id")),
|
|
170
|
+
activity_id=activity_id,
|
|
171
|
+
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "openbox-sdk-python"
|
|
3
|
-
version = "1.
|
|
3
|
+
version = "1.1.0"
|
|
4
4
|
description = "OpenBox base SDK - governance contracts, strict gate, identity/signing, evaluate client, context runtime, OTel span wire serialization, and generic instrumentation shared by every OpenBox framework SDK"
|
|
5
5
|
authors = [
|
|
6
6
|
{ name = "OpenBox Team", email = "tino@openbox.ai" },
|
|
@@ -26,7 +26,7 @@ dependencies = [
|
|
|
26
26
|
"opentelemetry-api>=1.38.0,<1.40.0",
|
|
27
27
|
"opentelemetry-sdk>=1.38.0,<1.40.0",
|
|
28
28
|
"httpx>=0.28.0,<1",
|
|
29
|
-
"cryptography>=
|
|
29
|
+
"cryptography>=48.0.1,<50",
|
|
30
30
|
]
|
|
31
31
|
|
|
32
32
|
[project.optional-dependencies]
|