openbox-langgraph-sdk-python 0.2.0__py3-none-any.whl → 1.1.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 +5 -0
- openbox_langgraph/activity_context_binding.py +126 -0
- openbox_langgraph/client.py +259 -51
- openbox_langgraph/config.py +86 -1
- openbox_langgraph/core_adapter.py +228 -0
- openbox_langgraph/core_events.py +141 -0
- openbox_langgraph/core_runtime.py +175 -0
- openbox_langgraph/errors.py +30 -1
- openbox_langgraph/identity.py +51 -27
- openbox_langgraph/langgraph_handler.py +839 -301
- openbox_langgraph/langgraph_hook_runtime.py +210 -0
- openbox_langgraph/otel_setup.py +23 -464
- 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.2.0.dist-info → openbox_langgraph_sdk_python-1.1.0.dist-info}/METADATA +58 -13
- openbox_langgraph_sdk_python-1.1.0.dist-info/RECORD +23 -0
- {openbox_langgraph_sdk_python-0.2.0.dist-info → openbox_langgraph_sdk_python-1.1.0.dist-info}/WHEEL +1 -1
- openbox_langgraph/db_governance_hooks.py +0 -897
- openbox_langgraph/file_governance_hooks.py +0 -419
- openbox_langgraph/hook_governance.py +0 -424
- openbox_langgraph/http_governance_hooks.py +0 -723
- openbox_langgraph_sdk_python-0.2.0.dist-info/RECORD +0 -20
- {openbox_langgraph_sdk_python-0.2.0.dist-info → openbox_langgraph_sdk_python-1.1.0.dist-info}/licenses/LICENSE +0 -0
openbox_langgraph/__init__.py
CHANGED
|
@@ -17,6 +17,8 @@ Example:
|
|
|
17
17
|
... )
|
|
18
18
|
"""
|
|
19
19
|
|
|
20
|
+
__version__ = "1.1.0"
|
|
21
|
+
|
|
20
22
|
from openbox_langgraph.client import GovernanceClient, build_auth_headers
|
|
21
23
|
from openbox_langgraph.config import (
|
|
22
24
|
GovernanceConfig,
|
|
@@ -36,6 +38,7 @@ from openbox_langgraph.errors import (
|
|
|
36
38
|
OpenBoxError,
|
|
37
39
|
OpenBoxInsecureURLError,
|
|
38
40
|
OpenBoxNetworkError,
|
|
41
|
+
OpenBoxSigningError,
|
|
39
42
|
)
|
|
40
43
|
from openbox_langgraph.hitl import poll_until_decision
|
|
41
44
|
from openbox_langgraph.identity import (
|
|
@@ -110,11 +113,13 @@ __all__ = [
|
|
|
110
113
|
"OpenBoxLangGraphHandler",
|
|
111
114
|
"OpenBoxLangGraphHandlerOptions",
|
|
112
115
|
"OpenBoxNetworkError",
|
|
116
|
+
"OpenBoxSigningError",
|
|
113
117
|
"Verdict",
|
|
114
118
|
"VerdictContext",
|
|
115
119
|
"WorkflowEventType",
|
|
116
120
|
"WorkflowSpanBuffer",
|
|
117
121
|
"WorkflowSpanProcessor",
|
|
122
|
+
"__version__",
|
|
118
123
|
"build_agent_identity_canonical_request",
|
|
119
124
|
"build_auth_headers",
|
|
120
125
|
"create_agent_identity_headers",
|
|
@@ -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
|
@@ -5,16 +5,28 @@ from __future__ import annotations
|
|
|
5
5
|
import json
|
|
6
6
|
import os
|
|
7
7
|
from dataclasses import dataclass
|
|
8
|
-
from
|
|
9
|
-
from typing import Any
|
|
8
|
+
from typing import TYPE_CHECKING, Any
|
|
10
9
|
|
|
11
10
|
import httpx
|
|
12
|
-
|
|
13
|
-
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 OpenBoxConfigError as _CoreOpenBoxConfigError
|
|
17
|
+
|
|
18
|
+
from openbox_langgraph.core_events import to_envelope
|
|
19
|
+
from openbox_langgraph.errors import (
|
|
20
|
+
OpenBoxConfigError,
|
|
21
|
+
OpenBoxError,
|
|
22
|
+
OpenBoxNetworkError,
|
|
23
|
+
_raise_core_error,
|
|
24
|
+
)
|
|
14
25
|
from openbox_langgraph.identity import (
|
|
15
26
|
AgentIdentityConfig,
|
|
16
27
|
create_agent_identity_headers,
|
|
17
28
|
parse_optional_agent_identity_config,
|
|
29
|
+
parse_optional_workload_private_key,
|
|
18
30
|
)
|
|
19
31
|
from openbox_langgraph.types import (
|
|
20
32
|
ApprovalResponse,
|
|
@@ -25,7 +37,110 @@ from openbox_langgraph.types import (
|
|
|
25
37
|
to_server_event_type,
|
|
26
38
|
)
|
|
27
39
|
|
|
28
|
-
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
from openbox_core.client import EvaluationClient
|
|
42
|
+
from openbox_core.gate import GovernanceGate
|
|
43
|
+
|
|
44
|
+
_SDK_PACKAGE_VERSION = "1.1.0"
|
|
45
|
+
_SDK_IDENTIFIER = f"openbox-langgraph-python-v{_SDK_PACKAGE_VERSION}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _network_fallback_result(on_api_error: str, msg: str) -> GovernanceVerdictResponse | None:
|
|
49
|
+
"""Apply the `on_api_error` policy to a NETWORK/transport failure.
|
|
50
|
+
|
|
51
|
+
This is the ONLY place a `GovernanceClient` verdict call may return
|
|
52
|
+
`None` — it corresponds exactly to a client-synthesized fallback
|
|
53
|
+
(`EvaluationResult.fallback_allow`): `verdict=ALLOW`, `fallback_used=True`,
|
|
54
|
+
and an EMPTY `raw` dict (nothing was ever parsed from a real body).
|
|
55
|
+
|
|
56
|
+
A response BODY that happens to be `{"verdict": "block",
|
|
57
|
+
"fallback_used": true}` never reaches this function — it is parsed by
|
|
58
|
+
`_verdict_from_response_data` instead, whose `raw` is always the
|
|
59
|
+
non-empty parsed dict, so the None-collapse below can never fire for it
|
|
60
|
+
and the BLOCK is enforced normally.
|
|
61
|
+
|
|
62
|
+
`msg` is the caller's fully-formatted message (kept caller-side so the
|
|
63
|
+
two distinct failure messages — "Governance API error: HTTP {status}"
|
|
64
|
+
for a bad response, "Governance API unreachable: {e}" for a raised
|
|
65
|
+
exception — stay exactly as they were before this translation layer).
|
|
66
|
+
"""
|
|
67
|
+
if on_api_error == "fail_closed":
|
|
68
|
+
raise OpenBoxNetworkError(msg)
|
|
69
|
+
result = EvaluationResult.fallback_allow(msg)
|
|
70
|
+
return _collapse_client_synthesized_fallback(result)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _verdict_from_response_data(data: dict[str, Any]) -> GovernanceVerdictResponse:
|
|
74
|
+
"""Parse a real HTTP response body into a `GovernanceVerdictResponse`.
|
|
75
|
+
|
|
76
|
+
Routed through the base SDK's `EvaluationResult.from_dict` so `raw` is
|
|
77
|
+
always the full parsed body — the non-empty `raw` is exactly what keeps
|
|
78
|
+
`_collapse_client_synthesized_fallback` from ever mistaking a real
|
|
79
|
+
(even oddly-shaped) Core response for a client-side fallback.
|
|
80
|
+
"""
|
|
81
|
+
return GovernanceVerdictResponse.from_result(EvaluationResult.from_dict(data))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _collapse_client_synthesized_fallback(
|
|
85
|
+
result: EvaluationResult,
|
|
86
|
+
) -> GovernanceVerdictResponse | None:
|
|
87
|
+
"""Return `None` ONLY for a client-synthesized fail-open fallback.
|
|
88
|
+
|
|
89
|
+
The three-part discriminator matches `EvaluationResult.fallback_allow`
|
|
90
|
+
exactly and nothing else: `fallback_used=True` AND `verdict is ALLOW` AND
|
|
91
|
+
`raw` is empty (no real body was ever parsed). A response body that
|
|
92
|
+
happens to carry `fallback_used: true` alongside a blocking verdict, or
|
|
93
|
+
alongside ALLOW but WITH a real (non-empty) body, is a real Core response
|
|
94
|
+
and must be returned as a `GovernanceVerdictResponse` so callers enforce
|
|
95
|
+
it — never silently collapsed to `None`/implicit-ALLOW.
|
|
96
|
+
"""
|
|
97
|
+
if result.fallback_used and result.verdict is _CoreVerdict.ALLOW and not result.raw:
|
|
98
|
+
return None
|
|
99
|
+
return GovernanceVerdictResponse.from_result(result)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
async def _gate_evaluate(
|
|
103
|
+
gate: GovernanceGate, event: LangChainGovernanceEvent, on_api_error: str
|
|
104
|
+
) -> GovernanceVerdictResponse | None:
|
|
105
|
+
"""Evaluate one lifecycle event through the base SDK's strict gate.
|
|
106
|
+
|
|
107
|
+
The single translation seam between `gate.aevaluate`'s base-SDK contract
|
|
108
|
+
(`EvaluationResult`, `openbox_core` exceptions) and this SDK's own
|
|
109
|
+
(`GovernanceVerdictResponse | None`, `openbox_langgraph.errors`) — every
|
|
110
|
+
gate-routed call site in `evaluate_event` goes through this function so
|
|
111
|
+
the translation is defined exactly once. Outcome policy (mirrors the legacy
|
|
112
|
+
httpx path so the wired transport is behaviourally interchangeable):
|
|
113
|
+
|
|
114
|
+
- `EvaluationResult.fallback_allow()` (client-synthesized fail-open on a
|
|
115
|
+
NETWORK error): collapsed to `None` via the same discriminator used for
|
|
116
|
+
the legacy path, so the pre-screen-`None` -> callback re-evaluation ->
|
|
117
|
+
PII-redaction flow keeps firing regardless of which transport produced it.
|
|
118
|
+
- `ContractError` (a malformed envelope — a bug in THIS SDK's own
|
|
119
|
+
event->envelope mapping, raised pre-network by the strict gate, never
|
|
120
|
+
from a Core response): ALWAYS a fail-open telemetry-drop (`None`),
|
|
121
|
+
independent of `on_api_error`. Enforcing fail_closed here would let an
|
|
122
|
+
SDK-side mapping defect block a user's graph for a reason their OWN policy
|
|
123
|
+
never produced — strictly worse than dropping one governance event.
|
|
124
|
+
- Base authentication, signing, and configuration errors always propagate
|
|
125
|
+
through this SDK's public errors, including under fail_open.
|
|
126
|
+
- `GovernanceAPIError` / `OpenBoxNetworkError` -> this SDK's
|
|
127
|
+
`OpenBoxNetworkError`. Bootstrap/token-exchange failures must propagate;
|
|
128
|
+
the base client applies fail_open only to ordinary governance transport errors.
|
|
129
|
+
- Any OTHER exception (e.g. a malformed Core 200 body the base parser
|
|
130
|
+
cannot decode) is a transport-shaped fault, NOT a governance verdict:
|
|
131
|
+
routed through `_network_fallback_result` so fail_open returns `None`
|
|
132
|
+
(never crash the graph on a Core hiccup) and fail_closed raises
|
|
133
|
+
`OpenBoxNetworkError` — matching the legacy httpx catch-all exactly.
|
|
134
|
+
"""
|
|
135
|
+
try:
|
|
136
|
+
result = await gate.aevaluate(to_envelope(event))
|
|
137
|
+
except _CoreContractError:
|
|
138
|
+
return None
|
|
139
|
+
except (_CoreGovernanceAPIError, _CoreOpenBoxConfigError) as e:
|
|
140
|
+
_raise_core_error(e)
|
|
141
|
+
except Exception as e:
|
|
142
|
+
return _network_fallback_result(on_api_error, f"Governance gate error: {e}")
|
|
143
|
+
return _collapse_client_synthesized_fallback(result)
|
|
29
144
|
|
|
30
145
|
|
|
31
146
|
def build_auth_headers(
|
|
@@ -38,13 +153,14 @@ def build_auth_headers(
|
|
|
38
153
|
) -> dict[str, str]:
|
|
39
154
|
"""Build standard auth headers for governance API calls.
|
|
40
155
|
|
|
41
|
-
|
|
156
|
+
Compatibility helper for API-key/DID requests. Workload headers are composed
|
|
157
|
+
by the base SDK after bootstrap and token exchange.
|
|
42
158
|
"""
|
|
43
159
|
headers = {
|
|
44
160
|
"Authorization": f"Bearer {api_key}",
|
|
45
161
|
"Content-Type": "application/json",
|
|
46
|
-
"User-Agent": f"OpenBox-LangGraph-SDK/{
|
|
47
|
-
"X-OpenBox-SDK-Version":
|
|
162
|
+
"User-Agent": f"OpenBox-LangGraph-SDK/{_SDK_IDENTIFIER}",
|
|
163
|
+
"X-OpenBox-SDK-Version": _SDK_IDENTIFIER,
|
|
48
164
|
}
|
|
49
165
|
if agent_identity:
|
|
50
166
|
if method is None or pathname is None:
|
|
@@ -87,6 +203,9 @@ class GovernanceClient:
|
|
|
87
203
|
on_api_error: str = "fail_open",
|
|
88
204
|
agent_did: str | None = None,
|
|
89
205
|
agent_private_key: str | None = None,
|
|
206
|
+
gate: GovernanceGate | None = None,
|
|
207
|
+
workload_private_key: str | None = None,
|
|
208
|
+
core_client: EvaluationClient | None = None,
|
|
90
209
|
) -> None:
|
|
91
210
|
self._api_url = api_url.rstrip("/")
|
|
92
211
|
self._api_key = api_key
|
|
@@ -98,9 +217,43 @@ class GovernanceClient:
|
|
|
98
217
|
did=agent_did,
|
|
99
218
|
private_key=agent_private_key,
|
|
100
219
|
)
|
|
220
|
+
workload_private_key = parse_optional_workload_private_key(workload_private_key)
|
|
221
|
+
self._core_client = core_client
|
|
222
|
+
self._owns_core_client = core_client is None and workload_private_key is not None
|
|
223
|
+
if self._owns_core_client:
|
|
224
|
+
from openbox_core.client import EvaluationClient
|
|
225
|
+
from openbox_core.identity import AgentIdentity
|
|
226
|
+
|
|
227
|
+
self._core_client = EvaluationClient(
|
|
228
|
+
self._api_url,
|
|
229
|
+
self._api_key,
|
|
230
|
+
timeout_seconds=timeout,
|
|
231
|
+
on_api_error=on_api_error,
|
|
232
|
+
identity=(
|
|
233
|
+
AgentIdentity.from_private_key(
|
|
234
|
+
self._agent_identity.did, self._agent_identity.private_key
|
|
235
|
+
)
|
|
236
|
+
if self._agent_identity
|
|
237
|
+
else None
|
|
238
|
+
),
|
|
239
|
+
workload_private_key=workload_private_key,
|
|
240
|
+
sdk_version=_SDK_PACKAGE_VERSION,
|
|
241
|
+
sdk_engine="langgraph",
|
|
242
|
+
)
|
|
243
|
+
# Optional base-SDK gate. When wired (by the handler, from a core
|
|
244
|
+
# runtime built off the SAME api_url/api_key/timeout/on_api_error),
|
|
245
|
+
# `evaluate_event`'s ASYNC path routes lifecycle events through it
|
|
246
|
+
# instead of this client's own httpx transport — see `evaluate_event`.
|
|
247
|
+
# `None` (the default) preserves the exact legacy transport/serialization
|
|
248
|
+
# for every existing caller that constructs a bare `GovernanceClient()`.
|
|
249
|
+
# The handler also lends its runtime client for sync/raw calls and
|
|
250
|
+
# approval polling, so every path shares the same workload token cache.
|
|
251
|
+
self._gate = gate
|
|
101
252
|
# Deduplication: prevent sending the same (activity_id, event_type) twice
|
|
102
253
|
# within the same workflow run. Keyed by (workflow_id, run_id) so it resets
|
|
103
|
-
# automatically on each new ainvoke() call.
|
|
254
|
+
# automatically on each new ainvoke() call. Shared by every evaluate_event
|
|
255
|
+
# call site regardless of which transport (gate or legacy httpx) is active
|
|
256
|
+
# for a given call — dedup is a client-level concern, not a transport one.
|
|
104
257
|
self._dedup_run: tuple[str, str] | None = None
|
|
105
258
|
self._dedup_sent: set[tuple[str, str]] = set()
|
|
106
259
|
|
|
@@ -117,7 +270,9 @@ class GovernanceClient:
|
|
|
117
270
|
return self._sync_client
|
|
118
271
|
|
|
119
272
|
async def close(self) -> None:
|
|
120
|
-
"""Close
|
|
273
|
+
"""Close owned HTTP clients; a borrowed runtime client belongs to its runtime."""
|
|
274
|
+
if self._owns_core_client and self._core_client is not None:
|
|
275
|
+
await self._core_client.aclose()
|
|
121
276
|
if self._client and not self._client.is_closed:
|
|
122
277
|
await self._client.aclose()
|
|
123
278
|
self._client = None
|
|
@@ -138,6 +293,13 @@ class GovernanceClient:
|
|
|
138
293
|
"""
|
|
139
294
|
from openbox_langgraph.errors import OpenBoxAuthError
|
|
140
295
|
|
|
296
|
+
if self._core_client is not None:
|
|
297
|
+
try:
|
|
298
|
+
await self._core_client.avalidate_api_key()
|
|
299
|
+
except _CoreOpenBoxConfigError as exc:
|
|
300
|
+
_raise_core_error(exc)
|
|
301
|
+
return
|
|
302
|
+
|
|
141
303
|
try:
|
|
142
304
|
client = self._get_client()
|
|
143
305
|
response = await client.get(
|
|
@@ -186,13 +348,23 @@ class GovernanceClient:
|
|
|
186
348
|
"""Send a governance event to OpenBox Core and return the verdict.
|
|
187
349
|
|
|
188
350
|
Returns `None` on network failure when `on_api_error` is `fail_open`.
|
|
189
|
-
Silently drops duplicate (activity_id, event_type) pairs within the same run
|
|
351
|
+
Silently drops duplicate (activity_id, event_type) pairs within the same run
|
|
352
|
+
— this de-dup pre-check runs BEFORE either transport below, so it applies
|
|
353
|
+
identically whether a `gate` is wired or not.
|
|
354
|
+
|
|
355
|
+
When a `gate` was supplied at construction (see `__init__`), the event is
|
|
356
|
+
routed through the base SDK's `EventEnvelope` + `GovernanceGate.aevaluate`
|
|
357
|
+
instead of this client's own httpx transport — see `_gate_evaluate`.
|
|
358
|
+
Overriding `evaluate_event` in a subclass (e.g. the golden-fixture
|
|
359
|
+
harness's `RecordingGovernanceClient`) still fully intercepts either way,
|
|
360
|
+
since the branch lives inside THIS method, never at a call site.
|
|
190
361
|
|
|
191
362
|
Args:
|
|
192
363
|
event: The governance event payload to evaluate.
|
|
193
364
|
|
|
194
365
|
Raises:
|
|
195
|
-
OpenBoxNetworkError: On network failure when `on_api_error` is `fail_closed
|
|
366
|
+
OpenBoxNetworkError: On network failure when `on_api_error` is `fail_closed`
|
|
367
|
+
(from either transport).
|
|
196
368
|
"""
|
|
197
369
|
server_event_type = to_server_event_type(event.event_type)
|
|
198
370
|
if event.activity_id and self._is_duplicate(
|
|
@@ -205,18 +377,34 @@ class GovernanceClient:
|
|
|
205
377
|
)
|
|
206
378
|
return None
|
|
207
379
|
|
|
208
|
-
payload = event.to_dict()
|
|
209
|
-
payload["event_type"] = server_event_type
|
|
210
|
-
payload["task_queue"] = event.task_queue or "langgraph"
|
|
211
|
-
payload["source"] = "workflow-telemetry"
|
|
212
|
-
|
|
213
380
|
if os.environ.get("OPENBOX_DEBUG") == "1":
|
|
214
381
|
import json
|
|
215
382
|
|
|
216
383
|
print(
|
|
217
|
-
f"[OpenBox Debug] governance request:
|
|
384
|
+
f"[OpenBox Debug] governance request: "
|
|
385
|
+
f"{json.dumps(event.to_dict(), indent=2, default=str)}"
|
|
218
386
|
)
|
|
219
387
|
|
|
388
|
+
if self._gate is not None:
|
|
389
|
+
try:
|
|
390
|
+
return await _gate_evaluate(self._gate, event, self._on_api_error)
|
|
391
|
+
except OpenBoxError:
|
|
392
|
+
self._forget_failed_event(event, server_event_type)
|
|
393
|
+
raise
|
|
394
|
+
|
|
395
|
+
payload = event.to_dict()
|
|
396
|
+
payload["event_type"] = server_event_type
|
|
397
|
+
payload["task_queue"] = event.task_queue or "langgraph"
|
|
398
|
+
payload["source"] = "workflow-telemetry"
|
|
399
|
+
|
|
400
|
+
if self._core_client is not None:
|
|
401
|
+
try:
|
|
402
|
+
result = await self._core_client.aevaluate(payload)
|
|
403
|
+
except (_CoreOpenBoxConfigError, _CoreGovernanceAPIError) as exc:
|
|
404
|
+
self._forget_failed_event(event, server_event_type)
|
|
405
|
+
_raise_core_error(exc)
|
|
406
|
+
return _collapse_client_synthesized_fallback(result)
|
|
407
|
+
|
|
220
408
|
try:
|
|
221
409
|
client = self._get_client()
|
|
222
410
|
body = _json_body(payload)
|
|
@@ -231,21 +419,17 @@ class GovernanceClient:
|
|
|
231
419
|
)
|
|
232
420
|
|
|
233
421
|
if not response.is_success:
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
return None
|
|
422
|
+
return _network_fallback_result(
|
|
423
|
+
self._on_api_error, f"Governance API error: HTTP {response.status_code}"
|
|
424
|
+
)
|
|
238
425
|
|
|
239
426
|
data = response.json()
|
|
240
|
-
return
|
|
427
|
+
return _verdict_from_response_data(data)
|
|
241
428
|
|
|
242
429
|
except OpenBoxNetworkError:
|
|
243
430
|
raise
|
|
244
431
|
except Exception as e:
|
|
245
|
-
|
|
246
|
-
msg = f"Governance API unreachable: {e}"
|
|
247
|
-
raise OpenBoxNetworkError(msg) from e
|
|
248
|
-
return None
|
|
432
|
+
return _network_fallback_result(self._on_api_error, f"Governance API unreachable: {e}")
|
|
249
433
|
|
|
250
434
|
def evaluate_event_sync(
|
|
251
435
|
self, event: LangChainGovernanceEvent
|
|
@@ -266,6 +450,14 @@ class GovernanceClient:
|
|
|
266
450
|
payload["task_queue"] = event.task_queue or "langgraph"
|
|
267
451
|
payload["source"] = "workflow-telemetry"
|
|
268
452
|
|
|
453
|
+
if self._core_client is not None:
|
|
454
|
+
try:
|
|
455
|
+
result = self._core_client.evaluate(payload)
|
|
456
|
+
except (_CoreOpenBoxConfigError, _CoreGovernanceAPIError) as exc:
|
|
457
|
+
self._forget_failed_event(event, server_event_type)
|
|
458
|
+
_raise_core_error(exc)
|
|
459
|
+
return _collapse_client_synthesized_fallback(result)
|
|
460
|
+
|
|
269
461
|
if os.environ.get("OPENBOX_DEBUG") == "1":
|
|
270
462
|
import json
|
|
271
463
|
|
|
@@ -288,30 +480,36 @@ class GovernanceClient:
|
|
|
288
480
|
)
|
|
289
481
|
|
|
290
482
|
if not response.is_success:
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
return None
|
|
483
|
+
return _network_fallback_result(
|
|
484
|
+
self._on_api_error, f"Governance API error: HTTP {response.status_code}"
|
|
485
|
+
)
|
|
295
486
|
|
|
296
487
|
data = response.json()
|
|
297
|
-
return
|
|
488
|
+
return _verdict_from_response_data(data)
|
|
298
489
|
|
|
299
490
|
except OpenBoxNetworkError:
|
|
300
491
|
raise
|
|
301
492
|
except Exception as e:
|
|
302
|
-
|
|
303
|
-
msg = f"Governance API unreachable: {e}"
|
|
304
|
-
raise OpenBoxNetworkError(msg) from e
|
|
305
|
-
return None
|
|
493
|
+
return _network_fallback_result(self._on_api_error, f"Governance API unreachable: {e}")
|
|
306
494
|
|
|
307
495
|
async def poll_approval(self, params: ApprovalPollParams) -> ApprovalResponse | None:
|
|
308
496
|
"""Poll for HITL approval status.
|
|
309
497
|
|
|
310
|
-
Returns `None` on
|
|
498
|
+
Returns `None` on ordinary polling transport failure so the caller can retry.
|
|
499
|
+
Workload authentication and bootstrap failures always raise.
|
|
311
500
|
|
|
312
501
|
Args:
|
|
313
502
|
params: Identifiers for the pending approval.
|
|
314
503
|
"""
|
|
504
|
+
if self._core_client is not None:
|
|
505
|
+
try:
|
|
506
|
+
result = await self._core_client.apoll_approval(
|
|
507
|
+
params.workflow_id, params.run_id, params.activity_id
|
|
508
|
+
)
|
|
509
|
+
except _CoreOpenBoxConfigError as exc:
|
|
510
|
+
_raise_core_error(exc)
|
|
511
|
+
return ApprovalResponse.from_result(result) if result is not None else None
|
|
512
|
+
|
|
315
513
|
try:
|
|
316
514
|
client = self._get_client()
|
|
317
515
|
body = _json_body(
|
|
@@ -335,19 +533,15 @@ class GovernanceClient:
|
|
|
335
533
|
return None
|
|
336
534
|
|
|
337
535
|
data = response.json()
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
#
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
if expiry < datetime.now(tz=UTC):
|
|
348
|
-
parsed.expired = True
|
|
349
|
-
|
|
350
|
-
return parsed
|
|
536
|
+
# SDK-side expiration check — run on the raw dict BEFORE parsing
|
|
537
|
+
# (matches openbox_core.client.check_expiration's own call order:
|
|
538
|
+
# check_expiration(data) then ApprovalResult.from_dict(data)).
|
|
539
|
+
# Handles ISO 'Z', ISO offset, and space-separated DB timestamp
|
|
540
|
+
# formats; a malformed timestamp is logged and left un-flagged
|
|
541
|
+
# rather than raised, so one bad timestamp string degrades to
|
|
542
|
+
# "expiration not confirmed" instead of aborting the whole poll.
|
|
543
|
+
check_expiration(data)
|
|
544
|
+
return parse_approval_response(data)
|
|
351
545
|
|
|
352
546
|
except Exception:
|
|
353
547
|
return None
|
|
@@ -359,8 +553,17 @@ class GovernanceClient:
|
|
|
359
553
|
by the caller (no event_type translation needed).
|
|
360
554
|
|
|
361
555
|
Args:
|
|
362
|
-
payload: The raw dict to POST to
|
|
556
|
+
payload: The raw dict to POST to the identity-appropriate evaluate route.
|
|
363
557
|
"""
|
|
558
|
+
if self._core_client is not None:
|
|
559
|
+
try:
|
|
560
|
+
result = await self._core_client.aevaluate(payload)
|
|
561
|
+
except (_CoreOpenBoxConfigError, _CoreGovernanceAPIError) as exc:
|
|
562
|
+
_raise_core_error(exc)
|
|
563
|
+
if _collapse_client_synthesized_fallback(result) is None:
|
|
564
|
+
return None
|
|
565
|
+
return dict(result.raw)
|
|
566
|
+
|
|
364
567
|
if os.environ.get("OPENBOX_DEBUG") == "1":
|
|
365
568
|
import json
|
|
366
569
|
|
|
@@ -412,6 +615,11 @@ class GovernanceClient:
|
|
|
412
615
|
# Private helpers
|
|
413
616
|
# ─────────────────────────────────────────────────────────────
|
|
414
617
|
|
|
618
|
+
def _forget_failed_event(self, event: LangChainGovernanceEvent, event_type: str) -> None:
|
|
619
|
+
"""A caller retrying a rejected event must authenticate again, never dedup to ALLOW."""
|
|
620
|
+
if event.activity_id and self._dedup_run == (event.workflow_id, event.run_id):
|
|
621
|
+
self._dedup_sent.discard((event.activity_id, event_type))
|
|
622
|
+
|
|
415
623
|
def _headers(self, *, method: str, pathname: str, body: bytes | str | None) -> dict[str, str]:
|
|
416
624
|
return build_auth_headers(
|
|
417
625
|
self._api_key,
|