openbox-langgraph-sdk-python 0.1.2__py3-none-any.whl → 1.0.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.
- openbox_langgraph/__init__.py +17 -0
- openbox_langgraph/activity_context_binding.py +126 -0
- openbox_langgraph/client.py +261 -70
- openbox_langgraph/config.py +101 -13
- openbox_langgraph/core_adapter.py +223 -0
- openbox_langgraph/core_events.py +141 -0
- openbox_langgraph/core_runtime.py +169 -0
- openbox_langgraph/errors.py +4 -0
- openbox_langgraph/identity.py +170 -0
- openbox_langgraph/langgraph_handler.py +835 -300
- openbox_langgraph/langgraph_hook_runtime.py +201 -0
- openbox_langgraph/otel_setup.py +23 -455
- openbox_langgraph/span_processor.py +18 -6
- openbox_langgraph/tool_activity_binding.py +339 -0
- openbox_langgraph/trace_context_registry.py +197 -0
- openbox_langgraph/tracing.py +7 -127
- openbox_langgraph/types.py +168 -9
- {openbox_langgraph_sdk_python-0.1.2.dist-info → openbox_langgraph_sdk_python-1.0.0.dist-info}/METADATA +21 -9
- openbox_langgraph_sdk_python-1.0.0.dist-info/RECORD +23 -0
- {openbox_langgraph_sdk_python-0.1.2.dist-info → openbox_langgraph_sdk_python-1.0.0.dist-info}/WHEEL +1 -1
- openbox_langgraph_sdk_python-1.0.0.dist-info/licenses/LICENSE +21 -0
- openbox_langgraph/db_governance_hooks.py +0 -897
- openbox_langgraph/file_governance_hooks.py +0 -419
- openbox_langgraph/hook_governance.py +0 -397
- openbox_langgraph/http_governance_hooks.py +0 -723
- openbox_langgraph_sdk_python-0.1.2.dist-info/RECORD +0 -18
openbox_langgraph/__init__.py
CHANGED
|
@@ -17,6 +17,8 @@ Example:
|
|
|
17
17
|
... )
|
|
18
18
|
"""
|
|
19
19
|
|
|
20
|
+
__version__ = "1.0.0"
|
|
21
|
+
|
|
20
22
|
from openbox_langgraph.client import GovernanceClient, build_auth_headers
|
|
21
23
|
from openbox_langgraph.config import (
|
|
22
24
|
GovernanceConfig,
|
|
@@ -32,11 +34,19 @@ from openbox_langgraph.errors import (
|
|
|
32
34
|
GovernanceHaltError,
|
|
33
35
|
GuardrailsValidationError,
|
|
34
36
|
OpenBoxAuthError,
|
|
37
|
+
OpenBoxConfigError,
|
|
35
38
|
OpenBoxError,
|
|
36
39
|
OpenBoxInsecureURLError,
|
|
37
40
|
OpenBoxNetworkError,
|
|
38
41
|
)
|
|
39
42
|
from openbox_langgraph.hitl import poll_until_decision
|
|
43
|
+
from openbox_langgraph.identity import (
|
|
44
|
+
AgentIdentityConfig,
|
|
45
|
+
build_agent_identity_canonical_request,
|
|
46
|
+
create_agent_identity_headers,
|
|
47
|
+
parse_optional_agent_identity_config,
|
|
48
|
+
validate_agent_identity_config,
|
|
49
|
+
)
|
|
40
50
|
from openbox_langgraph.langgraph_handler import (
|
|
41
51
|
OpenBoxLangGraphHandler,
|
|
42
52
|
OpenBoxLangGraphHandlerOptions,
|
|
@@ -79,6 +89,7 @@ from openbox_langgraph.verdict_handler import (
|
|
|
79
89
|
|
|
80
90
|
__all__ = [
|
|
81
91
|
"DEFAULT_HITL_CONFIG",
|
|
92
|
+
"AgentIdentityConfig",
|
|
82
93
|
"ApprovalExpiredError",
|
|
83
94
|
"ApprovalRejectedError",
|
|
84
95
|
"ApprovalResponse",
|
|
@@ -95,6 +106,7 @@ __all__ = [
|
|
|
95
106
|
"LangChainGovernanceEvent",
|
|
96
107
|
"LangGraphStreamEvent",
|
|
97
108
|
"OpenBoxAuthError",
|
|
109
|
+
"OpenBoxConfigError",
|
|
98
110
|
"OpenBoxError",
|
|
99
111
|
"OpenBoxInsecureURLError",
|
|
100
112
|
"OpenBoxLangGraphHandler",
|
|
@@ -105,7 +117,10 @@ __all__ = [
|
|
|
105
117
|
"WorkflowEventType",
|
|
106
118
|
"WorkflowSpanBuffer",
|
|
107
119
|
"WorkflowSpanProcessor",
|
|
120
|
+
"__version__",
|
|
121
|
+
"build_agent_identity_canonical_request",
|
|
108
122
|
"build_auth_headers",
|
|
123
|
+
"create_agent_identity_headers",
|
|
109
124
|
"create_openbox_graph_handler",
|
|
110
125
|
"create_span",
|
|
111
126
|
"enforce_verdict",
|
|
@@ -117,12 +132,14 @@ __all__ = [
|
|
|
117
132
|
"merge_config",
|
|
118
133
|
"parse_approval_response",
|
|
119
134
|
"parse_governance_response",
|
|
135
|
+
"parse_optional_agent_identity_config",
|
|
120
136
|
"poll_until_decision",
|
|
121
137
|
"rfc3339_now",
|
|
122
138
|
"safe_serialize",
|
|
123
139
|
"setup_opentelemetry_for_governance",
|
|
124
140
|
"to_server_event_type",
|
|
125
141
|
"traced",
|
|
142
|
+
"validate_agent_identity_config",
|
|
126
143
|
"verdict_from_string",
|
|
127
144
|
"verdict_priority",
|
|
128
145
|
"verdict_requires_approval",
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Dual-write ActivityContext registration + per-turn cleanup for the opt-in
|
|
2
|
+
base-SDK core runtime.
|
|
3
|
+
|
|
4
|
+
`langgraph_handler.py` already registers trace/activity correlation with the
|
|
5
|
+
legacy `WorkflowSpanProcessor` at every tool/LLM start and clears it at
|
|
6
|
+
completion — that dual-write's counterpart lands here so the (opt-in, later
|
|
7
|
+
phase) core hook runtime can resolve the SAME activity via
|
|
8
|
+
`TraceContextRegistry` without the legacy processor's behavior changing by one
|
|
9
|
+
line. This module is INERT unless a handler has an actual core runtime — see
|
|
10
|
+
`should_dual_write`.
|
|
11
|
+
|
|
12
|
+
Trace-only, never a ContextVar bind: LangGraph tool/LLM execution runs inside
|
|
13
|
+
a spawned `asyncio.Task` (or, for sync tools, a `run_in_executor` worker
|
|
14
|
+
thread) that already snapshot its ContextVar chain before this module's
|
|
15
|
+
registration call can run — a bind here can never reach that code. See
|
|
16
|
+
`core_runtime.py`'s module docstring and `tests/test_contextvars_propagation.py`
|
|
17
|
+
for the empirical proof.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from openbox_core.contracts.context import ActivityContext
|
|
25
|
+
from openbox_core.runtime import OpenBoxRuntime
|
|
26
|
+
|
|
27
|
+
from openbox_langgraph.config import GovernanceConfig
|
|
28
|
+
from openbox_langgraph.core_runtime import get_trace_registry
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"build_activity_context",
|
|
32
|
+
"register_activity",
|
|
33
|
+
"should_dual_write",
|
|
34
|
+
"unregister_activity",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def should_dual_write(core_runtime: OpenBoxRuntime | None) -> bool:
|
|
39
|
+
"""True only when the handler owns a real core runtime.
|
|
40
|
+
|
|
41
|
+
Handlers built with an injected `client` (e.g. a test double, or a
|
|
42
|
+
subclass overriding `evaluate_event`) have `core_runtime is None` by
|
|
43
|
+
construction (`langgraph_handler.py.__init__`) — this dual-write is
|
|
44
|
+
legacy-only for them, exactly like the gate-routing it mirrors.
|
|
45
|
+
"""
|
|
46
|
+
return core_runtime is not None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def build_activity_context(
|
|
50
|
+
*,
|
|
51
|
+
config: GovernanceConfig,
|
|
52
|
+
workflow_id: str,
|
|
53
|
+
run_id: str,
|
|
54
|
+
activity_id: str,
|
|
55
|
+
activity_type: str,
|
|
56
|
+
activity_input: Any = None,
|
|
57
|
+
langgraph_node: str | None = None,
|
|
58
|
+
langgraph_step: int | None = None,
|
|
59
|
+
tool_type: str | None = None,
|
|
60
|
+
tool_name: str | None = None,
|
|
61
|
+
tool_call_id: str | None = None,
|
|
62
|
+
subagent_name: str | None = None,
|
|
63
|
+
parent_ids: list[str] | None = None,
|
|
64
|
+
) -> ActivityContext:
|
|
65
|
+
"""Map one LangGraph activity boundary onto the base SDK's `ActivityContext`.
|
|
66
|
+
|
|
67
|
+
`workflow_id`/`run_id` are the PER-TURN ids `langgraph_handler.py` mints in
|
|
68
|
+
`ainvoke`/`astream_governed`/`astream`/`astream_events` (NOT LangGraph's own
|
|
69
|
+
per-node `run_id`, which becomes `activity_id` instead) — matching the
|
|
70
|
+
legacy governance event's own workflow_id/run_id fields exactly, so a hook
|
|
71
|
+
resolving this context reports the same turn a dashboard operator sees
|
|
72
|
+
from the legacy path.
|
|
73
|
+
"""
|
|
74
|
+
metadata: dict[str, Any] = {}
|
|
75
|
+
if langgraph_node is not None:
|
|
76
|
+
metadata["node"] = langgraph_node
|
|
77
|
+
if langgraph_step is not None:
|
|
78
|
+
metadata["step"] = langgraph_step
|
|
79
|
+
if tool_type is not None:
|
|
80
|
+
metadata["tool_type"] = tool_type
|
|
81
|
+
if tool_name is not None:
|
|
82
|
+
metadata["tool_name"] = tool_name
|
|
83
|
+
if tool_call_id is not None:
|
|
84
|
+
metadata["tool_call_id"] = tool_call_id
|
|
85
|
+
if subagent_name is not None:
|
|
86
|
+
metadata["subagent_name"] = subagent_name
|
|
87
|
+
if parent_ids:
|
|
88
|
+
metadata["parent_ids"] = list(parent_ids)
|
|
89
|
+
|
|
90
|
+
return ActivityContext(
|
|
91
|
+
workflow_id=workflow_id,
|
|
92
|
+
run_id=run_id,
|
|
93
|
+
workflow_type=config.agent_name or "LangGraphRun",
|
|
94
|
+
task_queue=config.task_queue or "langgraph",
|
|
95
|
+
activity_id=activity_id,
|
|
96
|
+
activity_type=activity_type,
|
|
97
|
+
activity_input=activity_input,
|
|
98
|
+
agent_name=config.agent_name,
|
|
99
|
+
session_id=config.session_id,
|
|
100
|
+
multi_agent_session_id=config.multi_agent_session_id,
|
|
101
|
+
metadata=metadata,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def register_activity(
|
|
106
|
+
core_runtime: OpenBoxRuntime | None,
|
|
107
|
+
trace_id: int,
|
|
108
|
+
ctx: ActivityContext,
|
|
109
|
+
) -> None:
|
|
110
|
+
"""Trace-only dual-write into the runtime's private `TraceContextRegistry`.
|
|
111
|
+
|
|
112
|
+
No-op when `core_runtime` is `None` (injected-client handlers) — see
|
|
113
|
+
`should_dual_write`. Skips a zero/falsy `trace_id` the same way the legacy
|
|
114
|
+
`if trace_id:` guard at the call site already does, so a degraded OTel
|
|
115
|
+
span (no active provider) never registers a bogus all-zero correlation.
|
|
116
|
+
"""
|
|
117
|
+
if core_runtime is None or not trace_id:
|
|
118
|
+
return
|
|
119
|
+
get_trace_registry(core_runtime).register(trace_id, ctx)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def unregister_activity(core_runtime: OpenBoxRuntime | None, trace_id: int | None) -> None:
|
|
123
|
+
"""Counterpart cleanup for `register_activity` — activity completion."""
|
|
124
|
+
if core_runtime is None or not trace_id:
|
|
125
|
+
return
|
|
126
|
+
get_trace_registry(core_runtime).unregister(trace_id)
|
openbox_langgraph/client.py
CHANGED
|
@@ -2,14 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import json
|
|
5
6
|
import os
|
|
6
7
|
from dataclasses import dataclass
|
|
7
|
-
from
|
|
8
|
-
from typing import Any
|
|
8
|
+
from typing import TYPE_CHECKING, Any
|
|
9
9
|
|
|
10
10
|
import httpx
|
|
11
|
-
|
|
12
|
-
from
|
|
11
|
+
from openbox_core.client import check_expiration
|
|
12
|
+
from openbox_core.contracts.results import EvaluationResult
|
|
13
|
+
from openbox_core.contracts.results import Verdict as _CoreVerdict
|
|
14
|
+
from openbox_core.errors import ContractError as _CoreContractError
|
|
15
|
+
from openbox_core.errors import GovernanceAPIError as _CoreGovernanceAPIError
|
|
16
|
+
from openbox_core.errors import OpenBoxNetworkError as _CoreOpenBoxNetworkError
|
|
17
|
+
|
|
18
|
+
from openbox_langgraph.core_events import to_envelope
|
|
19
|
+
from openbox_langgraph.errors import OpenBoxConfigError, OpenBoxNetworkError
|
|
20
|
+
from openbox_langgraph.identity import (
|
|
21
|
+
AgentIdentityConfig,
|
|
22
|
+
create_agent_identity_headers,
|
|
23
|
+
parse_optional_agent_identity_config,
|
|
24
|
+
)
|
|
13
25
|
from openbox_langgraph.types import (
|
|
14
26
|
ApprovalResponse,
|
|
15
27
|
GovernanceVerdictResponse,
|
|
@@ -19,20 +31,141 @@ from openbox_langgraph.types import (
|
|
|
19
31
|
to_server_event_type,
|
|
20
32
|
)
|
|
21
33
|
|
|
22
|
-
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from openbox_core.gate import GovernanceGate
|
|
36
|
+
|
|
37
|
+
_SDK_PACKAGE_VERSION = "1.0.0"
|
|
38
|
+
_SDK_IDENTIFIER = f"openbox-langgraph-python-v{_SDK_PACKAGE_VERSION}"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _network_fallback_result(on_api_error: str, msg: str) -> GovernanceVerdictResponse | None:
|
|
42
|
+
"""Apply the `on_api_error` policy to a NETWORK/transport failure.
|
|
43
|
+
|
|
44
|
+
This is the ONLY place a `GovernanceClient` verdict call may return
|
|
45
|
+
`None` — it corresponds exactly to a client-synthesized fallback
|
|
46
|
+
(`EvaluationResult.fallback_allow`): `verdict=ALLOW`, `fallback_used=True`,
|
|
47
|
+
and an EMPTY `raw` dict (nothing was ever parsed from a real body).
|
|
48
|
+
|
|
49
|
+
A response BODY that happens to be `{"verdict": "block",
|
|
50
|
+
"fallback_used": true}` never reaches this function — it is parsed by
|
|
51
|
+
`_verdict_from_response_data` instead, whose `raw` is always the
|
|
52
|
+
non-empty parsed dict, so the None-collapse below can never fire for it
|
|
53
|
+
and the BLOCK is enforced normally.
|
|
54
|
+
|
|
55
|
+
`msg` is the caller's fully-formatted message (kept caller-side so the
|
|
56
|
+
two distinct failure messages — "Governance API error: HTTP {status}"
|
|
57
|
+
for a bad response, "Governance API unreachable: {e}" for a raised
|
|
58
|
+
exception — stay exactly as they were before this translation layer).
|
|
59
|
+
"""
|
|
60
|
+
if on_api_error == "fail_closed":
|
|
61
|
+
raise OpenBoxNetworkError(msg)
|
|
62
|
+
result = EvaluationResult.fallback_allow(msg)
|
|
63
|
+
return _collapse_client_synthesized_fallback(result)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _verdict_from_response_data(data: dict[str, Any]) -> GovernanceVerdictResponse:
|
|
67
|
+
"""Parse a real HTTP response body into a `GovernanceVerdictResponse`.
|
|
68
|
+
|
|
69
|
+
Routed through the base SDK's `EvaluationResult.from_dict` so `raw` is
|
|
70
|
+
always the full parsed body — the non-empty `raw` is exactly what keeps
|
|
71
|
+
`_collapse_client_synthesized_fallback` from ever mistaking a real
|
|
72
|
+
(even oddly-shaped) Core response for a client-side fallback.
|
|
73
|
+
"""
|
|
74
|
+
return GovernanceVerdictResponse.from_result(EvaluationResult.from_dict(data))
|
|
75
|
+
|
|
23
76
|
|
|
77
|
+
def _collapse_client_synthesized_fallback(
|
|
78
|
+
result: EvaluationResult,
|
|
79
|
+
) -> GovernanceVerdictResponse | None:
|
|
80
|
+
"""Return `None` ONLY for a client-synthesized fail-open fallback.
|
|
24
81
|
|
|
25
|
-
|
|
82
|
+
The three-part discriminator matches `EvaluationResult.fallback_allow`
|
|
83
|
+
exactly and nothing else: `fallback_used=True` AND `verdict is ALLOW` AND
|
|
84
|
+
`raw` is empty (no real body was ever parsed). A response body that
|
|
85
|
+
happens to carry `fallback_used: true` alongside a blocking verdict, or
|
|
86
|
+
alongside ALLOW but WITH a real (non-empty) body, is a real Core response
|
|
87
|
+
and must be returned as a `GovernanceVerdictResponse` so callers enforce
|
|
88
|
+
it — never silently collapsed to `None`/implicit-ALLOW.
|
|
89
|
+
"""
|
|
90
|
+
if result.fallback_used and result.verdict is _CoreVerdict.ALLOW and not result.raw:
|
|
91
|
+
return None
|
|
92
|
+
return GovernanceVerdictResponse.from_result(result)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
async def _gate_evaluate(
|
|
96
|
+
gate: GovernanceGate, event: LangChainGovernanceEvent, on_api_error: str
|
|
97
|
+
) -> GovernanceVerdictResponse | None:
|
|
98
|
+
"""Evaluate one lifecycle event through the base SDK's strict gate.
|
|
99
|
+
|
|
100
|
+
The single translation seam between `gate.aevaluate`'s base-SDK contract
|
|
101
|
+
(`EvaluationResult`, `openbox_core` exceptions) and this SDK's own
|
|
102
|
+
(`GovernanceVerdictResponse | None`, `openbox_langgraph.errors`) — every
|
|
103
|
+
gate-routed call site in `evaluate_event` goes through this function so
|
|
104
|
+
the translation is defined exactly once. Outcome policy (mirrors the legacy
|
|
105
|
+
httpx path so the wired transport is behaviourally interchangeable):
|
|
106
|
+
|
|
107
|
+
- `EvaluationResult.fallback_allow()` (client-synthesized fail-open on a
|
|
108
|
+
NETWORK error): collapsed to `None` via the same discriminator used for
|
|
109
|
+
the legacy path, so the pre-screen-`None` -> callback re-evaluation ->
|
|
110
|
+
PII-redaction flow keeps firing regardless of which transport produced it.
|
|
111
|
+
- `ContractError` (a malformed envelope — a bug in THIS SDK's own
|
|
112
|
+
event->envelope mapping, raised pre-network by the strict gate, never
|
|
113
|
+
from a Core response): ALWAYS a fail-open telemetry-drop (`None`),
|
|
114
|
+
independent of `on_api_error`. Enforcing fail_closed here would let an
|
|
115
|
+
SDK-side mapping defect block a user's graph for a reason their OWN policy
|
|
116
|
+
never produced — strictly worse than dropping one governance event.
|
|
117
|
+
- `GovernanceAPIError` / `OpenBoxNetworkError` (network-shaped failure) ->
|
|
118
|
+
this SDK's `OpenBoxNetworkError`, same public exception the legacy path
|
|
119
|
+
raises under fail_closed.
|
|
120
|
+
- Any OTHER exception (e.g. a malformed Core 200 body the base parser
|
|
121
|
+
cannot decode) is a transport-shaped fault, NOT a governance verdict:
|
|
122
|
+
routed through `_network_fallback_result` so fail_open returns `None`
|
|
123
|
+
(never crash the graph on a Core hiccup) and fail_closed raises
|
|
124
|
+
`OpenBoxNetworkError` — matching the legacy httpx catch-all exactly.
|
|
125
|
+
"""
|
|
126
|
+
try:
|
|
127
|
+
result = await gate.aevaluate(to_envelope(event))
|
|
128
|
+
except _CoreContractError:
|
|
129
|
+
return None
|
|
130
|
+
except (_CoreGovernanceAPIError, _CoreOpenBoxNetworkError) as e:
|
|
131
|
+
raise OpenBoxNetworkError(str(e)) from e
|
|
132
|
+
except Exception as e:
|
|
133
|
+
return _network_fallback_result(on_api_error, f"Governance gate error: {e}")
|
|
134
|
+
return _collapse_client_synthesized_fallback(result)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def build_auth_headers(
|
|
138
|
+
api_key: str,
|
|
139
|
+
*,
|
|
140
|
+
method: str | None = None,
|
|
141
|
+
pathname: str | None = None,
|
|
142
|
+
body: bytes | str | None = None,
|
|
143
|
+
agent_identity: AgentIdentityConfig | None = None,
|
|
144
|
+
) -> dict[str, str]:
|
|
26
145
|
"""Build standard auth headers for governance API calls.
|
|
27
146
|
|
|
28
|
-
Single source of truth
|
|
147
|
+
Single source of truth for the SDK's outbound governance requests.
|
|
29
148
|
"""
|
|
30
|
-
|
|
149
|
+
headers = {
|
|
31
150
|
"Authorization": f"Bearer {api_key}",
|
|
32
151
|
"Content-Type": "application/json",
|
|
33
|
-
"User-Agent": f"OpenBox-LangGraph-SDK/{
|
|
34
|
-
"X-OpenBox-SDK-Version":
|
|
152
|
+
"User-Agent": f"OpenBox-LangGraph-SDK/{_SDK_IDENTIFIER}",
|
|
153
|
+
"X-OpenBox-SDK-Version": _SDK_IDENTIFIER,
|
|
35
154
|
}
|
|
155
|
+
if agent_identity:
|
|
156
|
+
if method is None or pathname is None:
|
|
157
|
+
msg = "method and pathname are required when signing OpenBox requests."
|
|
158
|
+
raise OpenBoxConfigError(msg)
|
|
159
|
+
headers.update(
|
|
160
|
+
create_agent_identity_headers(
|
|
161
|
+
did=agent_identity.did,
|
|
162
|
+
private_key=agent_identity.private_key,
|
|
163
|
+
method=method,
|
|
164
|
+
pathname=pathname,
|
|
165
|
+
body=body,
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
return headers
|
|
36
169
|
|
|
37
170
|
|
|
38
171
|
@dataclass
|
|
@@ -58,6 +191,9 @@ class GovernanceClient:
|
|
|
58
191
|
api_key: str,
|
|
59
192
|
timeout: float = 30.0, # seconds
|
|
60
193
|
on_api_error: str = "fail_open",
|
|
194
|
+
agent_did: str | None = None,
|
|
195
|
+
agent_private_key: str | None = None,
|
|
196
|
+
gate: GovernanceGate | None = None,
|
|
61
197
|
) -> None:
|
|
62
198
|
self._api_url = api_url.rstrip("/")
|
|
63
199
|
self._api_key = api_key
|
|
@@ -65,10 +201,23 @@ class GovernanceClient:
|
|
|
65
201
|
self._on_api_error = on_api_error
|
|
66
202
|
self._client: httpx.AsyncClient | None = None
|
|
67
203
|
self._sync_client: httpx.Client | None = None
|
|
68
|
-
self.
|
|
204
|
+
self._agent_identity = parse_optional_agent_identity_config(
|
|
205
|
+
did=agent_did,
|
|
206
|
+
private_key=agent_private_key,
|
|
207
|
+
)
|
|
208
|
+
# Optional base-SDK gate. When wired (by the handler, from a core
|
|
209
|
+
# runtime built off the SAME api_url/api_key/timeout/on_api_error),
|
|
210
|
+
# `evaluate_event`'s ASYNC path routes lifecycle events through it
|
|
211
|
+
# instead of this client's own httpx transport — see `evaluate_event`.
|
|
212
|
+
# `None` (the default) preserves the exact legacy transport/serialization
|
|
213
|
+
# for every existing caller that constructs a bare `GovernanceClient()`.
|
|
214
|
+
# `evaluate_event_sync` (sync middleware hooks) is unaffected either way.
|
|
215
|
+
self._gate = gate
|
|
69
216
|
# Deduplication: prevent sending the same (activity_id, event_type) twice
|
|
70
217
|
# within the same workflow run. Keyed by (workflow_id, run_id) so it resets
|
|
71
|
-
# automatically on each new ainvoke() call.
|
|
218
|
+
# automatically on each new ainvoke() call. Shared by every evaluate_event
|
|
219
|
+
# call site regardless of which transport (gate or legacy httpx) is active
|
|
220
|
+
# for a given call — dedup is a client-level concern, not a transport one.
|
|
72
221
|
self._dedup_run: tuple[str, str] | None = None
|
|
73
222
|
self._dedup_sent: set[tuple[str, str]] = set()
|
|
74
223
|
|
|
@@ -110,7 +259,11 @@ class GovernanceClient:
|
|
|
110
259
|
client = self._get_client()
|
|
111
260
|
response = await client.get(
|
|
112
261
|
f"{self._api_url}/api/v1/auth/validate",
|
|
113
|
-
headers=self._headers(
|
|
262
|
+
headers=self._headers(
|
|
263
|
+
method="GET",
|
|
264
|
+
pathname="/api/v1/auth/validate",
|
|
265
|
+
body=b"",
|
|
266
|
+
),
|
|
114
267
|
)
|
|
115
268
|
if response.status_code in (401, 403):
|
|
116
269
|
msg = "Invalid API key. Check your API key at dashboard.openbox.ai"
|
|
@@ -150,13 +303,23 @@ class GovernanceClient:
|
|
|
150
303
|
"""Send a governance event to OpenBox Core and return the verdict.
|
|
151
304
|
|
|
152
305
|
Returns `None` on network failure when `on_api_error` is `fail_open`.
|
|
153
|
-
Silently drops duplicate (activity_id, event_type) pairs within the same run
|
|
306
|
+
Silently drops duplicate (activity_id, event_type) pairs within the same run
|
|
307
|
+
— this de-dup pre-check runs BEFORE either transport below, so it applies
|
|
308
|
+
identically whether a `gate` is wired or not.
|
|
309
|
+
|
|
310
|
+
When a `gate` was supplied at construction (see `__init__`), the event is
|
|
311
|
+
routed through the base SDK's `EventEnvelope` + `GovernanceGate.aevaluate`
|
|
312
|
+
instead of this client's own httpx transport — see `_gate_evaluate`.
|
|
313
|
+
Overriding `evaluate_event` in a subclass (e.g. the golden-fixture
|
|
314
|
+
harness's `RecordingGovernanceClient`) still fully intercepts either way,
|
|
315
|
+
since the branch lives inside THIS method, never at a call site.
|
|
154
316
|
|
|
155
317
|
Args:
|
|
156
318
|
event: The governance event payload to evaluate.
|
|
157
319
|
|
|
158
320
|
Raises:
|
|
159
|
-
OpenBoxNetworkError: On network failure when `on_api_error` is `fail_closed
|
|
321
|
+
OpenBoxNetworkError: On network failure when `on_api_error` is `fail_closed`
|
|
322
|
+
(from either transport).
|
|
160
323
|
"""
|
|
161
324
|
server_event_type = to_server_event_type(event.event_type)
|
|
162
325
|
if event.activity_id and self._is_duplicate(
|
|
@@ -169,41 +332,49 @@ class GovernanceClient:
|
|
|
169
332
|
)
|
|
170
333
|
return None
|
|
171
334
|
|
|
172
|
-
payload = event.to_dict()
|
|
173
|
-
payload["event_type"] = server_event_type
|
|
174
|
-
payload["task_queue"] = event.task_queue or "langgraph"
|
|
175
|
-
payload["source"] = "workflow-telemetry"
|
|
176
|
-
|
|
177
335
|
if os.environ.get("OPENBOX_DEBUG") == "1":
|
|
178
336
|
import json
|
|
337
|
+
|
|
179
338
|
print(
|
|
180
|
-
f"[OpenBox Debug] governance request:
|
|
339
|
+
f"[OpenBox Debug] governance request: "
|
|
340
|
+
f"{json.dumps(event.to_dict(), indent=2, default=str)}"
|
|
181
341
|
)
|
|
182
342
|
|
|
343
|
+
if self._gate is not None:
|
|
344
|
+
return await _gate_evaluate(self._gate, event, self._on_api_error)
|
|
345
|
+
|
|
346
|
+
payload = event.to_dict()
|
|
347
|
+
payload["event_type"] = server_event_type
|
|
348
|
+
payload["task_queue"] = event.task_queue or "langgraph"
|
|
349
|
+
payload["source"] = "workflow-telemetry"
|
|
350
|
+
|
|
183
351
|
try:
|
|
184
352
|
client = self._get_client()
|
|
353
|
+
body = _json_body(payload)
|
|
185
354
|
response = await client.post(
|
|
186
355
|
f"{self._api_url}/api/v1/governance/evaluate",
|
|
187
|
-
headers=self._headers(
|
|
188
|
-
|
|
356
|
+
headers=self._headers(
|
|
357
|
+
method="POST",
|
|
358
|
+
pathname="/api/v1/governance/evaluate",
|
|
359
|
+
body=body,
|
|
360
|
+
),
|
|
361
|
+
content=body,
|
|
189
362
|
)
|
|
190
363
|
|
|
191
364
|
if not response.is_success:
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
return None
|
|
365
|
+
return _network_fallback_result(
|
|
366
|
+
self._on_api_error, f"Governance API error: HTTP {response.status_code}"
|
|
367
|
+
)
|
|
196
368
|
|
|
197
369
|
data = response.json()
|
|
198
|
-
return
|
|
370
|
+
return _verdict_from_response_data(data)
|
|
199
371
|
|
|
200
372
|
except OpenBoxNetworkError:
|
|
201
373
|
raise
|
|
202
374
|
except Exception as e:
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
return None
|
|
375
|
+
return _network_fallback_result(
|
|
376
|
+
self._on_api_error, f"Governance API unreachable: {e}"
|
|
377
|
+
)
|
|
207
378
|
|
|
208
379
|
def evaluate_event_sync(
|
|
209
380
|
self, event: LangChainGovernanceEvent
|
|
@@ -226,6 +397,7 @@ class GovernanceClient:
|
|
|
226
397
|
|
|
227
398
|
if os.environ.get("OPENBOX_DEBUG") == "1":
|
|
228
399
|
import json
|
|
400
|
+
|
|
229
401
|
print(
|
|
230
402
|
"[OpenBox Debug] sync governance request:"
|
|
231
403
|
f" {json.dumps(payload, indent=2, default=str)}"
|
|
@@ -233,32 +405,33 @@ class GovernanceClient:
|
|
|
233
405
|
|
|
234
406
|
try:
|
|
235
407
|
client = self._get_sync_client()
|
|
408
|
+
body = _json_body(payload)
|
|
236
409
|
response = client.post(
|
|
237
410
|
f"{self._api_url}/api/v1/governance/evaluate",
|
|
238
|
-
headers=self._headers(
|
|
239
|
-
|
|
411
|
+
headers=self._headers(
|
|
412
|
+
method="POST",
|
|
413
|
+
pathname="/api/v1/governance/evaluate",
|
|
414
|
+
body=body,
|
|
415
|
+
),
|
|
416
|
+
content=body,
|
|
240
417
|
)
|
|
241
418
|
|
|
242
419
|
if not response.is_success:
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
return None
|
|
420
|
+
return _network_fallback_result(
|
|
421
|
+
self._on_api_error, f"Governance API error: HTTP {response.status_code}"
|
|
422
|
+
)
|
|
247
423
|
|
|
248
424
|
data = response.json()
|
|
249
|
-
return
|
|
425
|
+
return _verdict_from_response_data(data)
|
|
250
426
|
|
|
251
427
|
except OpenBoxNetworkError:
|
|
252
428
|
raise
|
|
253
429
|
except Exception as e:
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
return None
|
|
430
|
+
return _network_fallback_result(
|
|
431
|
+
self._on_api_error, f"Governance API unreachable: {e}"
|
|
432
|
+
)
|
|
258
433
|
|
|
259
|
-
async def poll_approval(
|
|
260
|
-
self, params: ApprovalPollParams
|
|
261
|
-
) -> ApprovalResponse | None:
|
|
434
|
+
async def poll_approval(self, params: ApprovalPollParams) -> ApprovalResponse | None:
|
|
262
435
|
"""Poll for HITL approval status.
|
|
263
436
|
|
|
264
437
|
Returns `None` on network failure so the caller can retry.
|
|
@@ -268,39 +441,41 @@ class GovernanceClient:
|
|
|
268
441
|
"""
|
|
269
442
|
try:
|
|
270
443
|
client = self._get_client()
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
headers=self._headers(),
|
|
274
|
-
json={
|
|
444
|
+
body = _json_body(
|
|
445
|
+
{
|
|
275
446
|
"workflow_id": params.workflow_id,
|
|
276
447
|
"run_id": params.run_id,
|
|
277
448
|
"activity_id": params.activity_id,
|
|
278
|
-
}
|
|
449
|
+
}
|
|
450
|
+
)
|
|
451
|
+
response = await client.post(
|
|
452
|
+
f"{self._api_url}/api/v1/governance/approval",
|
|
453
|
+
headers=self._headers(
|
|
454
|
+
method="POST",
|
|
455
|
+
pathname="/api/v1/governance/approval",
|
|
456
|
+
body=body,
|
|
457
|
+
),
|
|
458
|
+
content=body,
|
|
279
459
|
)
|
|
280
460
|
|
|
281
461
|
if not response.is_success:
|
|
282
462
|
return None
|
|
283
463
|
|
|
284
464
|
data = response.json()
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
#
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
parsed.expired = True
|
|
295
|
-
|
|
296
|
-
return parsed
|
|
465
|
+
# SDK-side expiration check — run on the raw dict BEFORE parsing
|
|
466
|
+
# (matches openbox_core.client.check_expiration's own call order:
|
|
467
|
+
# check_expiration(data) then ApprovalResult.from_dict(data)).
|
|
468
|
+
# Handles ISO 'Z', ISO offset, and space-separated DB timestamp
|
|
469
|
+
# formats; a malformed timestamp is logged and left un-flagged
|
|
470
|
+
# rather than raised, so one bad timestamp string degrades to
|
|
471
|
+
# "expiration not confirmed" instead of aborting the whole poll.
|
|
472
|
+
check_expiration(data)
|
|
473
|
+
return parse_approval_response(data)
|
|
297
474
|
|
|
298
475
|
except Exception:
|
|
299
476
|
return None
|
|
300
477
|
|
|
301
|
-
async def evaluate_raw(
|
|
302
|
-
self, payload: dict[str, Any]
|
|
303
|
-
) -> dict[str, Any] | None:
|
|
478
|
+
async def evaluate_raw(self, payload: dict[str, Any]) -> dict[str, Any] | None:
|
|
304
479
|
"""Send a pre-built payload to the governance evaluate endpoint.
|
|
305
480
|
|
|
306
481
|
Used by hook-level governance where the payload is fully assembled
|
|
@@ -311,16 +486,22 @@ class GovernanceClient:
|
|
|
311
486
|
"""
|
|
312
487
|
if os.environ.get("OPENBOX_DEBUG") == "1":
|
|
313
488
|
import json
|
|
489
|
+
|
|
314
490
|
print(
|
|
315
491
|
f"[OpenBox Debug] span hook request: {json.dumps(payload, indent=2, default=str)}"
|
|
316
492
|
)
|
|
317
493
|
|
|
318
494
|
try:
|
|
319
495
|
client = self._get_client()
|
|
496
|
+
body = _json_body(payload)
|
|
320
497
|
response = await client.post(
|
|
321
498
|
f"{self._api_url}/api/v1/governance/evaluate",
|
|
322
|
-
headers=self._headers(
|
|
323
|
-
|
|
499
|
+
headers=self._headers(
|
|
500
|
+
method="POST",
|
|
501
|
+
pathname="/api/v1/governance/evaluate",
|
|
502
|
+
body=body,
|
|
503
|
+
),
|
|
504
|
+
content=body,
|
|
324
505
|
)
|
|
325
506
|
|
|
326
507
|
if not response.is_success:
|
|
@@ -354,5 +535,15 @@ class GovernanceClient:
|
|
|
354
535
|
# Private helpers
|
|
355
536
|
# ─────────────────────────────────────────────────────────────
|
|
356
537
|
|
|
357
|
-
def _headers(self) -> dict[str, str]:
|
|
358
|
-
return
|
|
538
|
+
def _headers(self, *, method: str, pathname: str, body: bytes | str | None) -> dict[str, str]:
|
|
539
|
+
return build_auth_headers(
|
|
540
|
+
self._api_key,
|
|
541
|
+
method=method,
|
|
542
|
+
pathname=pathname,
|
|
543
|
+
body=body,
|
|
544
|
+
agent_identity=self._agent_identity,
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _json_body(payload: dict[str, Any]) -> bytes:
|
|
549
|
+
return json.dumps(payload, separators=(",", ":"), default=str).encode("utf-8")
|