openbox-sdk-python 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. openbox_core/__init__.py +59 -0
  2. openbox_core/adapters/__init__.py +3 -0
  3. openbox_core/adapters/base.py +123 -0
  4. openbox_core/approvals.py +106 -0
  5. openbox_core/client.py +298 -0
  6. openbox_core/config.py +260 -0
  7. openbox_core/conformance/__init__.py +3 -0
  8. openbox_core/conformance/fake_core.py +169 -0
  9. openbox_core/conformance/hook_preflight.py +87 -0
  10. openbox_core/conformance/instrumentation.py +91 -0
  11. openbox_core/context.py +205 -0
  12. openbox_core/contracts/__init__.py +3 -0
  13. openbox_core/contracts/context.py +79 -0
  14. openbox_core/contracts/events.py +401 -0
  15. openbox_core/contracts/otel_spans.py +325 -0
  16. openbox_core/contracts/results.py +287 -0
  17. openbox_core/errors.py +287 -0
  18. openbox_core/gate.py +185 -0
  19. openbox_core/hooks/__init__.py +3 -0
  20. openbox_core/hooks/events.py +64 -0
  21. openbox_core/hooks/preflight.py +292 -0
  22. openbox_core/hooks/wrappers.py +105 -0
  23. openbox_core/identity.py +231 -0
  24. openbox_core/instrumentation/__init__.py +3 -0
  25. openbox_core/instrumentation/db.py +689 -0
  26. openbox_core/instrumentation/file.py +239 -0
  27. openbox_core/instrumentation/function.py +121 -0
  28. openbox_core/instrumentation/http.py +840 -0
  29. openbox_core/instrumentation/llm.py +3 -0
  30. openbox_core/instrumentation/manager.py +135 -0
  31. openbox_core/instrumentation/shared.py +27 -0
  32. openbox_core/otel/__init__.py +3 -0
  33. openbox_core/otel/propagation.py +45 -0
  34. openbox_core/otel/provider.py +35 -0
  35. openbox_core/otel/setup.py +36 -0
  36. openbox_core/otel/span_processor.py +62 -0
  37. openbox_core/otel/trace_context.py +71 -0
  38. openbox_core/py.typed +0 -0
  39. openbox_core/runtime.py +138 -0
  40. openbox_core/sdk_version.py +79 -0
  41. openbox_core/serialization.py +129 -0
  42. openbox_core/validation/__init__.py +3 -0
  43. openbox_core/validation/diagnostics.py +60 -0
  44. openbox_core/validation/event_rules.py +164 -0
  45. openbox_core/validation/registry.py +31 -0
  46. openbox_core/validation/span_normalization.py +107 -0
  47. openbox_core/wire/__init__.py +3 -0
  48. openbox_core/wire/core_span.py +130 -0
  49. openbox_core/wire/evaluate_payload.py +56 -0
  50. openbox_sdk_python-0.2.0.dist-info/METADATA +94 -0
  51. openbox_sdk_python-0.2.0.dist-info/RECORD +52 -0
  52. openbox_sdk_python-0.2.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,3 @@
1
+ """LLM provider instrumentation placeholder."""
2
+
3
+ __all__: list[str] = []
@@ -0,0 +1,135 @@
1
+ """InstrumentationManager — idempotent install/uninstall of all wrappers.
2
+
3
+ Owned by OpenBoxRuntime. Install order matters:
4
+
5
+ 1. OTel provider + passive span processor (``install_opentelemetry`` toggle)
6
+ 2. cross-thread ContextVar executor patch (best-effort; needs a running loop)
7
+ 3. publish the HookRuntime to the shared instrumentation state
8
+ 4. HTTP / DB / file wrappers per config toggles (each deferral is LOGGED)
9
+
10
+ The evaluate call must never instrument itself: the ignored-URL set always
11
+ contains the configured ``api_url``.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ from typing import TYPE_CHECKING, Any
18
+
19
+ from ..otel.propagation import install_context_propagating_executor
20
+ from ..otel.setup import install_opentelemetry, shutdown_opentelemetry
21
+ from . import db as db_instrumentation
22
+ from . import file as file_instrumentation
23
+ from . import http as http_instrumentation
24
+ from .shared import get_hook_runtime, set_hook_runtime
25
+
26
+ if TYPE_CHECKING:
27
+ from ..runtime import OpenBoxRuntime
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ __all__ = ["InstrumentationManager"]
32
+
33
+
34
+ class InstrumentationManager:
35
+ """Install/uninstall lifecycle with per-target guard flags."""
36
+
37
+ def __init__(self, runtime: OpenBoxRuntime, *, extra_ignored_urls: set[str] | None = None):
38
+ self._runtime = runtime
39
+ self._installed = False
40
+ self._provider: Any = None
41
+ self._installed_targets: list[str] = []
42
+ self._extra_ignored_urls = set(extra_ignored_urls or ())
43
+
44
+ @property
45
+ def installed_targets(self) -> list[str]:
46
+ return list(self._installed_targets)
47
+
48
+ def install(self) -> None:
49
+ """Idempotent — a second install() is a no-op."""
50
+ if self._installed:
51
+ return
52
+ config = self._runtime.config
53
+ instrumentation = config.instrumentation
54
+
55
+ if instrumentation.install_opentelemetry:
56
+ self._provider, _ = install_opentelemetry(self._runtime.context_store)
57
+
58
+ # ContextVars → executor threads (no running loop ⇒ skipped, logged).
59
+ if not install_context_propagating_executor():
60
+ logger.info("executor ContextVar patch deferred (no running event loop)")
61
+
62
+ # Self-instrumentation guard: never govern our own evaluate calls.
63
+ http_instrumentation.set_ignored_url_prefixes(
64
+ {config.api_url, *self._extra_ignored_urls}
65
+ )
66
+
67
+ from ..hooks.preflight import HookRuntime
68
+
69
+ set_hook_runtime(HookRuntime(self._runtime))
70
+
71
+ if instrumentation.http_enabled:
72
+ if http_instrumentation.install_requests():
73
+ self._installed_targets.append("requests")
74
+ if http_instrumentation.install_httpx():
75
+ self._installed_targets.append("httpx")
76
+ # Body capture patches Client.send AFTER OTel instrumentation so
77
+ # the captured original send already carries the request hook.
78
+ if http_instrumentation.install_httpx_body_capture():
79
+ self._installed_targets.append("httpx_body_capture")
80
+ if http_instrumentation.install_urllib3():
81
+ self._installed_targets.append("urllib3")
82
+ if http_instrumentation.install_urllib():
83
+ self._installed_targets.append("urllib")
84
+ else:
85
+ logger.info("HTTP instrumentation disabled by config")
86
+
87
+ if instrumentation.db_enabled:
88
+ if db_instrumentation.install_sqlalchemy():
89
+ self._installed_targets.append("sqlalchemy")
90
+ if db_instrumentation.install_dbapi():
91
+ self._installed_targets.append("dbapi")
92
+ if db_instrumentation.install_asyncpg():
93
+ self._installed_targets.append("asyncpg")
94
+ if db_instrumentation.install_redis():
95
+ self._installed_targets.append("redis")
96
+ if db_instrumentation.install_pymongo():
97
+ self._installed_targets.append("pymongo")
98
+ else:
99
+ logger.info("DB instrumentation disabled by config")
100
+
101
+ if instrumentation.file_enabled:
102
+ if file_instrumentation.install_file_io():
103
+ self._installed_targets.append("file")
104
+ else:
105
+ logger.info("file instrumentation disabled by config (opt-in)")
106
+
107
+ # Function instrumentation is decorator-based (@governed) — nothing to
108
+ # patch; it activates through the published hook runtime.
109
+ logger.info(f"OpenBox instrumentation installed: {self._installed_targets}")
110
+ self._installed = True
111
+
112
+ def uninstall(self) -> None:
113
+ """Restore every original and flush OTel (idempotent, safe shutdown)."""
114
+ if not self._installed:
115
+ return
116
+ file_instrumentation.uninstall_file_io()
117
+ db_instrumentation.uninstall_pymongo()
118
+ db_instrumentation.uninstall_redis()
119
+ db_instrumentation.uninstall_asyncpg()
120
+ db_instrumentation.uninstall_dbapi()
121
+ db_instrumentation.uninstall_sqlalchemy()
122
+ # Reverse install order: unwind the send patch before OTel httpx.
123
+ http_instrumentation.uninstall_urllib()
124
+ http_instrumentation.uninstall_urllib3()
125
+ http_instrumentation.uninstall_httpx_body_capture()
126
+ http_instrumentation.uninstall_httpx()
127
+ http_instrumentation.uninstall_requests()
128
+ if get_hook_runtime() is not None:
129
+ set_hook_runtime(None)
130
+ if self._provider is not None:
131
+ shutdown_opentelemetry(self._provider)
132
+ self._provider = None
133
+ self._installed_targets.clear()
134
+ self._installed = False
135
+ logger.info("OpenBox instrumentation uninstalled")
@@ -0,0 +1,27 @@
1
+ """Shared instrumentation state — the active HookRuntime.
2
+
3
+ Patched call sites (module-level hooks, builtins patches) can't hold instance
4
+ references, so the manager publishes the active runtime here on install and
5
+ clears it on uninstall. One active runtime per process (matching the
6
+ one-global-config model of the existing SDKs).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import TYPE_CHECKING
12
+
13
+ if TYPE_CHECKING:
14
+ from ..hooks.preflight import HookRuntime
15
+
16
+ __all__ = ["set_hook_runtime", "get_hook_runtime"]
17
+
18
+ _hook_runtime: HookRuntime | None = None
19
+
20
+
21
+ def set_hook_runtime(runtime: HookRuntime | None) -> None:
22
+ global _hook_runtime
23
+ _hook_runtime = runtime
24
+
25
+
26
+ def get_hook_runtime() -> HookRuntime | None:
27
+ return _hook_runtime
@@ -0,0 +1,3 @@
1
+ """OTel provider/exporter lifecycle, span processing, trace context, and propagation."""
2
+
3
+ __all__: list[str] = []
@@ -0,0 +1,45 @@
1
+ """Cross-thread ContextVar propagation for run_in_executor threads.
2
+
3
+ Python < 3.12 does NOT copy ContextVars into default-executor threads: the
4
+ HTTP/DB instrumentors then create spans with trace_id=0 in the worker thread
5
+ and the hook runtime can't find the bound ActivityContext. Installing a
6
+ context-propagating executor fixes both on Python 3.11, the pinned floor.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import concurrent.futures
12
+ import contextvars
13
+ import functools
14
+ import logging
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ __all__ = ["ContextPropagatingExecutor", "install_context_propagating_executor"]
19
+
20
+
21
+ class ContextPropagatingExecutor(concurrent.futures.ThreadPoolExecutor):
22
+ """ThreadPoolExecutor that copies ContextVars into spawned threads."""
23
+
24
+ def submit(self, fn, /, *args, **kwargs):
25
+ ctx = contextvars.copy_context()
26
+ return super().submit(ctx.run, functools.partial(fn, *args, **kwargs))
27
+
28
+
29
+ def install_context_propagating_executor(max_workers: int = 32) -> bool:
30
+ """Install a ContextVar-propagating default executor on the running loop.
31
+
32
+ Only the DEFAULT executor (``run_in_executor(None, ...)``) is patched;
33
+ explicit user executors are untouched. Returns True when installed,
34
+ False when no loop is running (callers may retry from async context).
35
+ """
36
+ import asyncio
37
+
38
+ try:
39
+ loop = asyncio.get_running_loop()
40
+ except RuntimeError:
41
+ logger.debug("No running event loop — executor patch skipped")
42
+ return False
43
+ loop.set_default_executor(ContextPropagatingExecutor(max_workers=max_workers))
44
+ logger.info("Installed context-propagating default executor")
45
+ return True
@@ -0,0 +1,35 @@
1
+ """Create-or-reuse TracerProvider — never clobber a user-installed provider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Any
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+ __all__ = ["get_or_create_tracer_provider", "get_tracer"]
11
+
12
+
13
+ def get_or_create_tracer_provider() -> Any:
14
+ """Return the process TracerProvider, creating one only if none exists.
15
+
16
+ Attaches to an existing SDK provider (user-installed providers are
17
+ reused, never replaced); only the no-op default is upgraded to a real
18
+ ``opentelemetry.sdk.trace.TracerProvider``.
19
+ """
20
+ from opentelemetry import trace
21
+ from opentelemetry.sdk.trace import TracerProvider
22
+
23
+ provider = trace.get_tracer_provider()
24
+ if not isinstance(provider, TracerProvider):
25
+ provider = TracerProvider()
26
+ trace.set_tracer_provider(provider)
27
+ logger.info("Created OpenBox TracerProvider (no SDK provider was set)")
28
+ return provider
29
+
30
+
31
+ def get_tracer(name: str = "openbox_core") -> Any:
32
+ """Tracer for spans the SDK creates itself (db/file/function wrappers)."""
33
+ from opentelemetry import trace
34
+
35
+ return trace.get_tracer(name)
@@ -0,0 +1,36 @@
1
+ """install_opentelemetry()/shutdown_opentelemetry() — OTel lifecycle."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Any
7
+
8
+ from ..context import ContextStore
9
+ from .provider import get_or_create_tracer_provider
10
+ from .span_processor import OpenBoxSpanProcessor
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ __all__ = ["install_opentelemetry", "shutdown_opentelemetry"]
15
+
16
+
17
+ def install_opentelemetry(context_store: ContextStore) -> tuple[Any, OpenBoxSpanProcessor]:
18
+ """Attach the passive OpenBox span processor to the process provider.
19
+
20
+ Reuses an existing user provider; returns (provider, processor) so the
21
+ caller can flush/detach on shutdown.
22
+ """
23
+ provider = get_or_create_tracer_provider()
24
+ processor = OpenBoxSpanProcessor(context_store)
25
+ provider.add_span_processor(processor)
26
+ logger.info("Registered OpenBoxSpanProcessor with the TracerProvider")
27
+ return provider, processor
28
+
29
+
30
+ def shutdown_opentelemetry(provider: Any) -> None:
31
+ """Flush pending spans. The provider itself is left running — it may be
32
+ user-owned; the SDK only ever ADDS a processor, so it only flushes."""
33
+ try:
34
+ provider.force_flush(timeout_millis=5000)
35
+ except Exception:
36
+ logger.debug("TracerProvider force_flush failed", exc_info=True)
@@ -0,0 +1,62 @@
1
+ """OpenBoxSpanProcessor — a PASSIVE SpanProcessor.
2
+
3
+ It registers trace -> ActivityContext correlations so hook code can resolve
4
+ the bound context from any child span, and nothing else. It MUST NOT perform
5
+ preflight blocking: ``on_end`` fires after the operation already ran, so hard
6
+ blocking lives exclusively in the wrapper call path (hooks/wrappers.py).
7
+ Abort/halt registries live in the ContextStore/runtime — not here.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ from typing import Any
14
+
15
+ from opentelemetry.sdk.trace import SpanProcessor
16
+
17
+ from ..context import ContextStore
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ __all__ = ["OpenBoxSpanProcessor"]
22
+
23
+
24
+ class OpenBoxSpanProcessor(SpanProcessor):
25
+ """Passive correlation processor bound to a ContextStore."""
26
+
27
+ def __init__(self, context_store: ContextStore):
28
+ self._store = context_store
29
+
30
+ def on_start(self, span: Any, parent_context: Any = None) -> None:
31
+ """Register the span's trace to the CURRENTLY BOUND context (if any).
32
+
33
+ Frameworks register the activity root trace explicitly via
34
+ ``activity_scope``; this passive hook additionally catches spans whose
35
+ traces were minted after binding. No bound context ⇒ no-op.
36
+ """
37
+ try:
38
+ ctx = self._store.current_activity_context()
39
+ if ctx is None:
40
+ return
41
+ span_context = span.get_span_context()
42
+ trace_id = getattr(span_context, "trace_id", None)
43
+ if isinstance(trace_id, int) and trace_id != 0:
44
+ if self._store.context_for_trace(trace_id) is None:
45
+ self._store.register_trace(trace_id, ctx)
46
+ except Exception: # correlation must never break user spans
47
+ logger.debug("OpenBoxSpanProcessor.on_start correlation failed", exc_info=True)
48
+
49
+ def on_end(self, span: Any) -> None:
50
+ """PASSIVE by contract — never evaluates, never blocks, never raises.
51
+
52
+ ``on_end`` fires after the operation completed; a verdict here could
53
+ not stop anything. Hard preflight blocking happens in the wrapper
54
+ call path before the real operation.
55
+ """
56
+ return None
57
+
58
+ def shutdown(self) -> None:
59
+ return None
60
+
61
+ def force_flush(self, timeout_millis: int = 30000) -> bool:
62
+ return True
@@ -0,0 +1,71 @@
1
+ """Span-context extraction and hex-id formatting for wire payloads.
2
+
3
+ Two representations, never confused:
4
+ - INTERNAL: raw OTel integers (trace correlation keys — see context.py).
5
+ - WIRE: lowercase hex strings — ``span_id`` 16 chars, ``trace_id`` 32 chars,
6
+ ``parent_span_id`` 16 chars. Raw integers must NEVER reach the wire.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ __all__ = [
14
+ "format_span_id",
15
+ "format_trace_id",
16
+ "extract_span_context",
17
+ "raw_trace_id",
18
+ ]
19
+
20
+
21
+ def format_span_id(span_id: int) -> str:
22
+ """16-char lowercase hex."""
23
+ return format(span_id, "016x")
24
+
25
+
26
+ def format_trace_id(trace_id: int) -> str:
27
+ """32-char lowercase hex."""
28
+ return format(trace_id, "032x")
29
+
30
+
31
+ def _span_context_of(span: Any) -> Any:
32
+ if hasattr(span, "get_span_context"):
33
+ try:
34
+ return span.get_span_context()
35
+ except Exception:
36
+ return None
37
+ return getattr(span, "context", None)
38
+
39
+
40
+ def extract_span_context(span: Any) -> tuple[str, str, str | None]:
41
+ """(span_id_hex16, trace_id_hex32, parent_span_id_hex16 | None).
42
+
43
+ Handles NonRecordingSpan, mocks, and missing attributes safely — degraded
44
+ ids become all-zero hex, never an exception.
45
+ """
46
+ span_context = _span_context_of(span)
47
+ try:
48
+ span_id_value = getattr(span_context, "span_id", None)
49
+ span_id = format_span_id(span_id_value) if isinstance(span_id_value, int) else "0" * 16
50
+ except (AttributeError, TypeError):
51
+ span_id = "0" * 16
52
+ try:
53
+ trace_id_value = getattr(span_context, "trace_id", None)
54
+ trace_id = format_trace_id(trace_id_value) if isinstance(trace_id_value, int) else "0" * 32
55
+ except (AttributeError, TypeError):
56
+ trace_id = "0" * 32
57
+
58
+ parent_span_id = None
59
+ parent = getattr(span, "parent", None)
60
+ parent_id_value = getattr(parent, "span_id", None) if parent is not None else None
61
+ if isinstance(parent_id_value, int):
62
+ parent_span_id = format_span_id(parent_id_value)
63
+
64
+ return span_id, trace_id, parent_span_id
65
+
66
+
67
+ def raw_trace_id(span: Any) -> int | None:
68
+ """The raw integer trace id for context-store lookups (or None)."""
69
+ span_context = _span_context_of(span)
70
+ trace_id = getattr(span_context, "trace_id", None)
71
+ return trace_id if isinstance(trace_id, int) else None
openbox_core/py.typed ADDED
File without changes
@@ -0,0 +1,138 @@
1
+ """OpenBoxRuntime — owns config, adapter, gate, client, context store, and
2
+ (from the instrumentation phase) the instrumentation manager.
3
+
4
+ The runtime is the composition root for non-sandbox code:
5
+
6
+ config = OpenBoxConfig.resolve(...)
7
+ runtime = OpenBoxRuntime(config, adapter=MyFrameworkAdapter())
8
+ runtime.install_instrumentation()
9
+ ...
10
+ runtime.close()
11
+
12
+ Lifecycle helpers evaluate through the strict gate and delegate native
13
+ enforcement (block/halt/approval) to the adapter callbacks.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Any
19
+
20
+ from .adapters.base import CoreAdapter, FrameworkAdapter
21
+ from .client import EvaluationClient
22
+ from .config import OpenBoxConfig
23
+ from .context import ContextStore, default_context_store
24
+ from .contracts.events import EventEnvelope
25
+ from .contracts.results import EvaluationResult, Verdict
26
+ from .errors import GuardrailsValidationError
27
+ from .gate import GovernanceGate
28
+
29
+ __all__ = ["OpenBoxRuntime"]
30
+
31
+
32
+ def _default_payload_builder(config: OpenBoxConfig) -> Any:
33
+ """Wire the hook evaluate-body assembler (the single body-shape owner),
34
+ binding the runtime's privacy config into the gate's one-argument seam."""
35
+ from .wire.evaluate_payload import make_payload_builder
36
+
37
+ return make_payload_builder(config.privacy)
38
+
39
+
40
+ class OpenBoxRuntime:
41
+ """Composition root wiring config → identity → client → gate → adapter."""
42
+
43
+ def __init__(
44
+ self,
45
+ config: OpenBoxConfig,
46
+ adapter: FrameworkAdapter | None = None,
47
+ *,
48
+ client: EvaluationClient | None = None,
49
+ context_store: ContextStore | None = None,
50
+ payload_builder: Any = None,
51
+ ):
52
+ self.config = config
53
+ self.adapter: FrameworkAdapter = adapter if adapter is not None else CoreAdapter()
54
+ self.context_store = context_store if context_store is not None else default_context_store()
55
+ self.client = client if client is not None else EvaluationClient(
56
+ config.api_url,
57
+ config.api_key,
58
+ timeout_seconds=config.timeout_seconds,
59
+ on_api_error=config.on_api_error,
60
+ identity=config.load_identity(),
61
+ sdk_version=config.sdk_version,
62
+ sdk_engine=config.sdk_engine,
63
+ sdk_language=config.sdk_language,
64
+ )
65
+ self.gate = GovernanceGate(
66
+ self.client,
67
+ config,
68
+ payload_builder=payload_builder if payload_builder is not None else _default_payload_builder(config),
69
+ )
70
+ self._instrumentation_manager: Any = None # set by install_instrumentation
71
+
72
+ # ── Instrumentation lifecycle (real body lands with the manager) ──────
73
+
74
+ def install_instrumentation(self) -> None:
75
+ """Install generic instrumentation per InstrumentationConfig."""
76
+ if not self.config.instrumentation.enabled:
77
+ return
78
+ if self._instrumentation_manager is None:
79
+ from .instrumentation.manager import InstrumentationManager
80
+
81
+ self._instrumentation_manager = InstrumentationManager(self)
82
+ self._instrumentation_manager.install()
83
+
84
+ def uninstall_instrumentation(self) -> None:
85
+ """Uninstall instrumentation and restore originals (idempotent)."""
86
+ if self._instrumentation_manager is not None:
87
+ self._instrumentation_manager.uninstall()
88
+
89
+ # ── Lifecycle evaluation helpers ──────────────────────────────────────
90
+
91
+ def evaluate_lifecycle(self, event: EventEnvelope) -> EvaluationResult:
92
+ """Evaluate + enforce a lifecycle event (sync).
93
+
94
+ BLOCK/HALT delegate to ``adapter.raise_lifecycle_blocked``; a
95
+ guardrails failure raises before any approval flow. REQUIRE_APPROVAL
96
+ is returned to the caller on the sync path (approval flows are async —
97
+ use :meth:`aevaluate_lifecycle` to drive the adapter's approval flow).
98
+ """
99
+ result = self.gate.evaluate(event)
100
+ return self._enforce_lifecycle(result, drive_approval=False)
101
+
102
+ async def aevaluate_lifecycle(self, event: EventEnvelope) -> EvaluationResult:
103
+ """Async evaluate + enforce, including the adapter approval flow."""
104
+ result = await self.gate.aevaluate(event)
105
+ if result.verdict.requires_approval():
106
+ self._check_guardrails(result)
107
+ await self.adapter.handle_approval(result)
108
+ return result
109
+ return self._enforce_lifecycle(result, drive_approval=False)
110
+
111
+ def _enforce_lifecycle(self, result: EvaluationResult, *, drive_approval: bool) -> EvaluationResult:
112
+ if result.verdict.should_stop():
113
+ if result.verdict is Verdict.HALT:
114
+ self.context_store.request_halt()
115
+ self.adapter.raise_lifecycle_blocked(result)
116
+ self._check_guardrails(result)
117
+ return result
118
+
119
+ @staticmethod
120
+ def _check_guardrails(result: EvaluationResult) -> None:
121
+ # Guardrails failure outranks approval so it is never swallowed by HITL.
122
+ if result.guardrails and not result.guardrails.validation_passed:
123
+ reasons = result.guardrails.get_reason_strings()
124
+ raise GuardrailsValidationError(reasons or ["Guardrails validation failed"])
125
+
126
+ # ── Shutdown ──────────────────────────────────────────────────────────
127
+
128
+ def close(self) -> None:
129
+ """Uninstall instrumentation, clear correlation state, close transports."""
130
+ self.uninstall_instrumentation()
131
+ self.context_store.clear()
132
+ self.client.close()
133
+
134
+ async def aclose(self) -> None:
135
+ """Async :meth:`close` (also closes the async transport)."""
136
+ self.uninstall_instrumentation()
137
+ self.context_store.clear()
138
+ await self.client.aclose()
@@ -0,0 +1,79 @@
1
+ """SDK identifier formatting for OpenBox request headers.
2
+
3
+ The wire value is intentionally framework-branded rather than just a package
4
+ version so Core can distinguish SDK families:
5
+
6
+ openbox-{engine}-{language}-v{version}
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+
13
+ __all__ = [
14
+ "DEFAULT_SDK_ENGINE",
15
+ "DEFAULT_SDK_LANGUAGE",
16
+ "SDK_IDENTIFIER_PATTERN",
17
+ "build_sdk_identifier",
18
+ "normalize_sdk_version",
19
+ ]
20
+
21
+ DEFAULT_SDK_ENGINE = "base"
22
+ DEFAULT_SDK_LANGUAGE = "python"
23
+
24
+ _SLUG_PART_RE = re.compile(r"[^a-z0-9]+")
25
+ _VERSION_RE = re.compile(r"^\d+\.\d+(?:\.\d+)?(?:[-+][0-9A-Za-z.-]+)?$")
26
+
27
+ SDK_IDENTIFIER_PATTERN = re.compile(
28
+ r"^openbox-[a-z0-9]+(?:-[a-z0-9]+)*-[a-z0-9]+-v"
29
+ r"\d+\.\d+(?:\.\d+)?(?:[-+][0-9A-Za-z.-]+)?$"
30
+ )
31
+
32
+
33
+ def _slug(value: str, field_name: str) -> str:
34
+ text = str(value).strip().lower()
35
+ slug = _SLUG_PART_RE.sub("-", text).strip("-")
36
+ if not slug:
37
+ raise ValueError(f"{field_name} must not be empty")
38
+ return slug
39
+
40
+
41
+ def normalize_sdk_version(version: str) -> str:
42
+ """Return a canonical ``v``-prefixed SDK version component."""
43
+
44
+ raw = str(version).strip()
45
+ if raw.startswith(("v", "V")):
46
+ raw = raw[1:]
47
+ if not _VERSION_RE.fullmatch(raw):
48
+ raise ValueError(
49
+ "sdk version must look like '1.1' or '1.2.3' "
50
+ f"(optionally with a suffix), got {version!r}"
51
+ )
52
+ return f"v{raw}"
53
+
54
+
55
+ def build_sdk_identifier(
56
+ *,
57
+ engine: str = DEFAULT_SDK_ENGINE,
58
+ language: str = DEFAULT_SDK_LANGUAGE,
59
+ version: str | None = None,
60
+ ) -> str:
61
+ """Build ``openbox-{engine}-{language}-v{version}``.
62
+
63
+ ``version`` may be a raw package version (``1.2.3``), a ``v``-prefixed
64
+ version (``v1.2.3``), or an already formatted OpenBox SDK identifier.
65
+ """
66
+
67
+ if version is None:
68
+ from . import __version__ as version
69
+
70
+ raw = str(version).strip()
71
+ if raw.startswith("openbox-"):
72
+ if not SDK_IDENTIFIER_PATTERN.fullmatch(raw):
73
+ raise ValueError(f"invalid OpenBox SDK identifier: {version!r}")
74
+ return raw
75
+
76
+ return (
77
+ f"openbox-{_slug(engine, 'sdk engine')}-"
78
+ f"{_slug(language, 'sdk language')}-{normalize_sdk_version(raw)}"
79
+ )