openbox-langgraph-sdk-python 1.0.0__py3-none-any.whl → 1.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.
- openbox_langgraph/__init__.py +3 -1
- openbox_langgraph/activity_approval.py +156 -0
- openbox_langgraph/client.py +105 -20
- openbox_langgraph/config.py +55 -1
- openbox_langgraph/core_adapter.py +34 -77
- openbox_langgraph/core_runtime.py +7 -1
- openbox_langgraph/errors.py +30 -1
- openbox_langgraph/identity.py +14 -0
- openbox_langgraph/langgraph_handler.py +34 -147
- openbox_langgraph/langgraph_hook_runtime.py +9 -0
- {openbox_langgraph_sdk_python-1.0.0.dist-info → openbox_langgraph_sdk_python-1.2.0.dist-info}/METADATA +61 -9
- openbox_langgraph_sdk_python-1.2.0.dist-info/RECORD +24 -0
- {openbox_langgraph_sdk_python-1.0.0.dist-info → openbox_langgraph_sdk_python-1.2.0.dist-info}/WHEEL +1 -1
- openbox_langgraph_sdk_python-1.0.0.dist-info/RECORD +0 -23
- {openbox_langgraph_sdk_python-1.0.0.dist-info → openbox_langgraph_sdk_python-1.2.0.dist-info}/licenses/LICENSE +0 -0
openbox_langgraph/__init__.py
CHANGED
|
@@ -17,7 +17,7 @@ Example:
|
|
|
17
17
|
... )
|
|
18
18
|
"""
|
|
19
19
|
|
|
20
|
-
__version__ = "1.
|
|
20
|
+
__version__ = "1.2.0"
|
|
21
21
|
|
|
22
22
|
from openbox_langgraph.client import GovernanceClient, build_auth_headers
|
|
23
23
|
from openbox_langgraph.config import (
|
|
@@ -38,6 +38,7 @@ from openbox_langgraph.errors import (
|
|
|
38
38
|
OpenBoxError,
|
|
39
39
|
OpenBoxInsecureURLError,
|
|
40
40
|
OpenBoxNetworkError,
|
|
41
|
+
OpenBoxSigningError,
|
|
41
42
|
)
|
|
42
43
|
from openbox_langgraph.hitl import poll_until_decision
|
|
43
44
|
from openbox_langgraph.identity import (
|
|
@@ -112,6 +113,7 @@ __all__ = [
|
|
|
112
113
|
"OpenBoxLangGraphHandler",
|
|
113
114
|
"OpenBoxLangGraphHandlerOptions",
|
|
114
115
|
"OpenBoxNetworkError",
|
|
116
|
+
"OpenBoxSigningError",
|
|
115
117
|
"Verdict",
|
|
116
118
|
"VerdictContext",
|
|
117
119
|
"WorkflowEventType",
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Wait at the governed operation, preserving its stack and activity identity."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import threading
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
|
|
9
|
+
from openbox_core.client import EvaluationClient
|
|
10
|
+
from openbox_core.contracts.context import ActivityContext
|
|
11
|
+
from openbox_core.contracts.results import ApprovalResult, EvaluationResult, Verdict
|
|
12
|
+
|
|
13
|
+
from openbox_langgraph.errors import (
|
|
14
|
+
ApprovalExpiredError,
|
|
15
|
+
ApprovalRejectedError,
|
|
16
|
+
OpenBoxConfigError,
|
|
17
|
+
_raise_core_error,
|
|
18
|
+
)
|
|
19
|
+
from openbox_langgraph.types import HITLConfig
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class _ApprovalTurn:
|
|
24
|
+
cancelled: threading.Event = field(default_factory=threading.Event)
|
|
25
|
+
decisions: dict[
|
|
26
|
+
str, tuple[EvaluationResult, ApprovalExpiredError | ApprovalRejectedError | None]
|
|
27
|
+
] = field(default_factory=dict)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ActivityApprovalWaiter:
|
|
31
|
+
"""Share approval decisions between the sync and async tool callbacks.
|
|
32
|
+
|
|
33
|
+
The callbacks can enforce the same stashed evaluation twice. Only that
|
|
34
|
+
exact evaluation is reusable: a later hook verdict on the same activity
|
|
35
|
+
must get its own decision. All state is discarded when the turn ends.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, client: EvaluationClient, config: HITLConfig) -> None:
|
|
39
|
+
self._client = client
|
|
40
|
+
self._config = config
|
|
41
|
+
self._lock = threading.Lock()
|
|
42
|
+
self._turns: dict[tuple[str, str], _ApprovalTurn] = {}
|
|
43
|
+
|
|
44
|
+
def begin_turn(self, workflow_id: str, run_id: str) -> None:
|
|
45
|
+
with self._lock:
|
|
46
|
+
self._turns[(workflow_id, run_id)] = _ApprovalTurn()
|
|
47
|
+
|
|
48
|
+
def end_turn(self, workflow_id: str) -> None:
|
|
49
|
+
with self._lock:
|
|
50
|
+
for key in list(self._turns):
|
|
51
|
+
if key[0] == workflow_id:
|
|
52
|
+
self._turns.pop(key).cancelled.set()
|
|
53
|
+
|
|
54
|
+
def _turn(self, ctx: ActivityContext) -> _ApprovalTurn:
|
|
55
|
+
workflow_id, run_id, _ = self._identity(ctx)
|
|
56
|
+
with self._lock:
|
|
57
|
+
turn = self._turns.get((workflow_id, run_id))
|
|
58
|
+
if turn is None:
|
|
59
|
+
raise OpenBoxConfigError("Approval requires an active governed turn")
|
|
60
|
+
return turn
|
|
61
|
+
|
|
62
|
+
@staticmethod
|
|
63
|
+
def _identity(ctx: ActivityContext) -> tuple[str, str, str]:
|
|
64
|
+
if not ctx.workflow_id or not ctx.run_id or not ctx.activity_id:
|
|
65
|
+
raise OpenBoxConfigError("Approval requires workflow, run, and activity IDs")
|
|
66
|
+
return ctx.workflow_id, ctx.run_id, ctx.activity_id
|
|
67
|
+
|
|
68
|
+
@staticmethod
|
|
69
|
+
def _check_cancelled(turn: _ApprovalTurn) -> None:
|
|
70
|
+
# Cancelling an asyncio task does not stop its executor thread. Wake
|
|
71
|
+
# the sync waiter too, so a late approval cannot run an abandoned tool.
|
|
72
|
+
if turn.cancelled.is_set():
|
|
73
|
+
raise asyncio.CancelledError
|
|
74
|
+
|
|
75
|
+
def _reuse_decision(
|
|
76
|
+
self, turn: _ApprovalTurn, ctx: ActivityContext, result: EvaluationResult
|
|
77
|
+
) -> bool:
|
|
78
|
+
self._check_cancelled(turn)
|
|
79
|
+
with self._lock:
|
|
80
|
+
decision = turn.decisions.get(self._identity(ctx)[2])
|
|
81
|
+
if decision is None or decision[0] is not result:
|
|
82
|
+
return False
|
|
83
|
+
if decision[1] is not None:
|
|
84
|
+
raise decision[1]
|
|
85
|
+
return True
|
|
86
|
+
|
|
87
|
+
def _record_response(
|
|
88
|
+
self,
|
|
89
|
+
turn: _ApprovalTurn,
|
|
90
|
+
ctx: ActivityContext,
|
|
91
|
+
result: EvaluationResult,
|
|
92
|
+
response: ApprovalResult | None,
|
|
93
|
+
) -> bool:
|
|
94
|
+
self._check_cancelled(turn)
|
|
95
|
+
try:
|
|
96
|
+
approved = self._approved(response, ctx)
|
|
97
|
+
except (ApprovalExpiredError, ApprovalRejectedError) as exc:
|
|
98
|
+
with self._lock:
|
|
99
|
+
turn.decisions[self._identity(ctx)[2]] = (result, exc)
|
|
100
|
+
raise
|
|
101
|
+
if not approved:
|
|
102
|
+
return False
|
|
103
|
+
with self._lock:
|
|
104
|
+
turn.decisions[self._identity(ctx)[2]] = (result, None)
|
|
105
|
+
return True
|
|
106
|
+
|
|
107
|
+
@staticmethod
|
|
108
|
+
def _approved(response: ApprovalResult | None, ctx: ActivityContext) -> bool:
|
|
109
|
+
if response is None:
|
|
110
|
+
return False
|
|
111
|
+
if response.expired:
|
|
112
|
+
raise ApprovalExpiredError(f"Approval expired for {ctx.activity_type}")
|
|
113
|
+
if response.verdict in (Verdict.BLOCK, Verdict.HALT):
|
|
114
|
+
raise ApprovalRejectedError(
|
|
115
|
+
response.reason or f"Approval rejected for {ctx.activity_type}"
|
|
116
|
+
)
|
|
117
|
+
return response.verdict is Verdict.ALLOW
|
|
118
|
+
|
|
119
|
+
async def wait(self, result: EvaluationResult, ctx: ActivityContext) -> None:
|
|
120
|
+
turn = self._turn(ctx)
|
|
121
|
+
if self._reuse_decision(turn, ctx, result):
|
|
122
|
+
return
|
|
123
|
+
while True:
|
|
124
|
+
self._check_cancelled(turn)
|
|
125
|
+
try:
|
|
126
|
+
response = await self._client.apoll_approval(*self._identity(ctx))
|
|
127
|
+
except Exception as exc:
|
|
128
|
+
_raise_core_error(exc)
|
|
129
|
+
self._check_cancelled(turn)
|
|
130
|
+
if self._record_response(turn, ctx, result, response):
|
|
131
|
+
return
|
|
132
|
+
await asyncio.sleep(self._config.poll_interval_ms / 1000.0)
|
|
133
|
+
|
|
134
|
+
def wait_sync(self, result: EvaluationResult, ctx: ActivityContext) -> None:
|
|
135
|
+
turn = self._turn(ctx)
|
|
136
|
+
if self._reuse_decision(turn, ctx, result):
|
|
137
|
+
return
|
|
138
|
+
try:
|
|
139
|
+
asyncio.get_running_loop()
|
|
140
|
+
except RuntimeError:
|
|
141
|
+
pass # A sync tool runs in LangChain's executor thread.
|
|
142
|
+
else:
|
|
143
|
+
raise OpenBoxConfigError(
|
|
144
|
+
"A synchronous operation requiring approval cannot wait on the event-loop "
|
|
145
|
+
"thread. Use its async API or run it with asyncio.to_thread()."
|
|
146
|
+
)
|
|
147
|
+
while True:
|
|
148
|
+
self._check_cancelled(turn)
|
|
149
|
+
try:
|
|
150
|
+
response = self._client.poll_approval(*self._identity(ctx))
|
|
151
|
+
except Exception as exc:
|
|
152
|
+
_raise_core_error(exc)
|
|
153
|
+
self._check_cancelled(turn)
|
|
154
|
+
if self._record_response(turn, ctx, result, response):
|
|
155
|
+
return
|
|
156
|
+
turn.cancelled.wait(self._config.poll_interval_ms / 1000.0)
|
openbox_langgraph/client.py
CHANGED
|
@@ -13,14 +13,20 @@ from openbox_core.contracts.results import EvaluationResult
|
|
|
13
13
|
from openbox_core.contracts.results import Verdict as _CoreVerdict
|
|
14
14
|
from openbox_core.errors import ContractError as _CoreContractError
|
|
15
15
|
from openbox_core.errors import GovernanceAPIError as _CoreGovernanceAPIError
|
|
16
|
-
from openbox_core.errors import
|
|
16
|
+
from openbox_core.errors import OpenBoxConfigError as _CoreOpenBoxConfigError
|
|
17
17
|
|
|
18
18
|
from openbox_langgraph.core_events import to_envelope
|
|
19
|
-
from openbox_langgraph.errors import
|
|
19
|
+
from openbox_langgraph.errors import (
|
|
20
|
+
OpenBoxConfigError,
|
|
21
|
+
OpenBoxError,
|
|
22
|
+
OpenBoxNetworkError,
|
|
23
|
+
_raise_core_error,
|
|
24
|
+
)
|
|
20
25
|
from openbox_langgraph.identity import (
|
|
21
26
|
AgentIdentityConfig,
|
|
22
27
|
create_agent_identity_headers,
|
|
23
28
|
parse_optional_agent_identity_config,
|
|
29
|
+
parse_optional_workload_private_key,
|
|
24
30
|
)
|
|
25
31
|
from openbox_langgraph.types import (
|
|
26
32
|
ApprovalResponse,
|
|
@@ -32,9 +38,10 @@ from openbox_langgraph.types import (
|
|
|
32
38
|
)
|
|
33
39
|
|
|
34
40
|
if TYPE_CHECKING:
|
|
41
|
+
from openbox_core.client import EvaluationClient
|
|
35
42
|
from openbox_core.gate import GovernanceGate
|
|
36
43
|
|
|
37
|
-
_SDK_PACKAGE_VERSION = "1.
|
|
44
|
+
_SDK_PACKAGE_VERSION = "1.2.0"
|
|
38
45
|
_SDK_IDENTIFIER = f"openbox-langgraph-python-v{_SDK_PACKAGE_VERSION}"
|
|
39
46
|
|
|
40
47
|
|
|
@@ -114,9 +121,11 @@ async def _gate_evaluate(
|
|
|
114
121
|
independent of `on_api_error`. Enforcing fail_closed here would let an
|
|
115
122
|
SDK-side mapping defect block a user's graph for a reason their OWN policy
|
|
116
123
|
never produced — strictly worse than dropping one governance event.
|
|
117
|
-
-
|
|
118
|
-
this SDK's
|
|
119
|
-
|
|
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.
|
|
120
129
|
- Any OTHER exception (e.g. a malformed Core 200 body the base parser
|
|
121
130
|
cannot decode) is a transport-shaped fault, NOT a governance verdict:
|
|
122
131
|
routed through `_network_fallback_result` so fail_open returns `None`
|
|
@@ -127,8 +136,8 @@ async def _gate_evaluate(
|
|
|
127
136
|
result = await gate.aevaluate(to_envelope(event))
|
|
128
137
|
except _CoreContractError:
|
|
129
138
|
return None
|
|
130
|
-
except (_CoreGovernanceAPIError,
|
|
131
|
-
|
|
139
|
+
except (_CoreGovernanceAPIError, _CoreOpenBoxConfigError) as e:
|
|
140
|
+
_raise_core_error(e)
|
|
132
141
|
except Exception as e:
|
|
133
142
|
return _network_fallback_result(on_api_error, f"Governance gate error: {e}")
|
|
134
143
|
return _collapse_client_synthesized_fallback(result)
|
|
@@ -144,7 +153,8 @@ def build_auth_headers(
|
|
|
144
153
|
) -> dict[str, str]:
|
|
145
154
|
"""Build standard auth headers for governance API calls.
|
|
146
155
|
|
|
147
|
-
|
|
156
|
+
Compatibility helper for API-key/DID requests. Workload headers are composed
|
|
157
|
+
by the base SDK after bootstrap and token exchange.
|
|
148
158
|
"""
|
|
149
159
|
headers = {
|
|
150
160
|
"Authorization": f"Bearer {api_key}",
|
|
@@ -194,6 +204,8 @@ class GovernanceClient:
|
|
|
194
204
|
agent_did: str | None = None,
|
|
195
205
|
agent_private_key: str | None = None,
|
|
196
206
|
gate: GovernanceGate | None = None,
|
|
207
|
+
workload_private_key: str | None = None,
|
|
208
|
+
core_client: EvaluationClient | None = None,
|
|
197
209
|
) -> None:
|
|
198
210
|
self._api_url = api_url.rstrip("/")
|
|
199
211
|
self._api_key = api_key
|
|
@@ -205,13 +217,37 @@ class GovernanceClient:
|
|
|
205
217
|
did=agent_did,
|
|
206
218
|
private_key=agent_private_key,
|
|
207
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
|
+
)
|
|
208
243
|
# Optional base-SDK gate. When wired (by the handler, from a core
|
|
209
244
|
# runtime built off the SAME api_url/api_key/timeout/on_api_error),
|
|
210
245
|
# `evaluate_event`'s ASYNC path routes lifecycle events through it
|
|
211
246
|
# instead of this client's own httpx transport — see `evaluate_event`.
|
|
212
247
|
# `None` (the default) preserves the exact legacy transport/serialization
|
|
213
248
|
# for every existing caller that constructs a bare `GovernanceClient()`.
|
|
214
|
-
#
|
|
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.
|
|
215
251
|
self._gate = gate
|
|
216
252
|
# Deduplication: prevent sending the same (activity_id, event_type) twice
|
|
217
253
|
# within the same workflow run. Keyed by (workflow_id, run_id) so it resets
|
|
@@ -234,7 +270,9 @@ class GovernanceClient:
|
|
|
234
270
|
return self._sync_client
|
|
235
271
|
|
|
236
272
|
async def close(self) -> None:
|
|
237
|
-
"""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()
|
|
238
276
|
if self._client and not self._client.is_closed:
|
|
239
277
|
await self._client.aclose()
|
|
240
278
|
self._client = None
|
|
@@ -255,6 +293,13 @@ class GovernanceClient:
|
|
|
255
293
|
"""
|
|
256
294
|
from openbox_langgraph.errors import OpenBoxAuthError
|
|
257
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
|
+
|
|
258
303
|
try:
|
|
259
304
|
client = self._get_client()
|
|
260
305
|
response = await client.get(
|
|
@@ -341,13 +386,25 @@ class GovernanceClient:
|
|
|
341
386
|
)
|
|
342
387
|
|
|
343
388
|
if self._gate is not None:
|
|
344
|
-
|
|
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
|
|
345
394
|
|
|
346
395
|
payload = event.to_dict()
|
|
347
396
|
payload["event_type"] = server_event_type
|
|
348
397
|
payload["task_queue"] = event.task_queue or "langgraph"
|
|
349
398
|
payload["source"] = "workflow-telemetry"
|
|
350
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
|
+
|
|
351
408
|
try:
|
|
352
409
|
client = self._get_client()
|
|
353
410
|
body = _json_body(payload)
|
|
@@ -372,9 +429,7 @@ class GovernanceClient:
|
|
|
372
429
|
except OpenBoxNetworkError:
|
|
373
430
|
raise
|
|
374
431
|
except Exception as e:
|
|
375
|
-
return _network_fallback_result(
|
|
376
|
-
self._on_api_error, f"Governance API unreachable: {e}"
|
|
377
|
-
)
|
|
432
|
+
return _network_fallback_result(self._on_api_error, f"Governance API unreachable: {e}")
|
|
378
433
|
|
|
379
434
|
def evaluate_event_sync(
|
|
380
435
|
self, event: LangChainGovernanceEvent
|
|
@@ -395,6 +450,14 @@ class GovernanceClient:
|
|
|
395
450
|
payload["task_queue"] = event.task_queue or "langgraph"
|
|
396
451
|
payload["source"] = "workflow-telemetry"
|
|
397
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
|
+
|
|
398
461
|
if os.environ.get("OPENBOX_DEBUG") == "1":
|
|
399
462
|
import json
|
|
400
463
|
|
|
@@ -427,18 +490,26 @@ class GovernanceClient:
|
|
|
427
490
|
except OpenBoxNetworkError:
|
|
428
491
|
raise
|
|
429
492
|
except Exception as e:
|
|
430
|
-
return _network_fallback_result(
|
|
431
|
-
self._on_api_error, f"Governance API unreachable: {e}"
|
|
432
|
-
)
|
|
493
|
+
return _network_fallback_result(self._on_api_error, f"Governance API unreachable: {e}")
|
|
433
494
|
|
|
434
495
|
async def poll_approval(self, params: ApprovalPollParams) -> ApprovalResponse | None:
|
|
435
496
|
"""Poll for HITL approval status.
|
|
436
497
|
|
|
437
|
-
Returns `None` on
|
|
498
|
+
Returns `None` on ordinary polling transport failure so the caller can retry.
|
|
499
|
+
Workload authentication and bootstrap failures always raise.
|
|
438
500
|
|
|
439
501
|
Args:
|
|
440
502
|
params: Identifiers for the pending approval.
|
|
441
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
|
+
|
|
442
513
|
try:
|
|
443
514
|
client = self._get_client()
|
|
444
515
|
body = _json_body(
|
|
@@ -482,8 +553,17 @@ class GovernanceClient:
|
|
|
482
553
|
by the caller (no event_type translation needed).
|
|
483
554
|
|
|
484
555
|
Args:
|
|
485
|
-
payload: The raw dict to POST to
|
|
556
|
+
payload: The raw dict to POST to the identity-appropriate evaluate route.
|
|
486
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
|
+
|
|
487
567
|
if os.environ.get("OPENBOX_DEBUG") == "1":
|
|
488
568
|
import json
|
|
489
569
|
|
|
@@ -535,6 +615,11 @@ class GovernanceClient:
|
|
|
535
615
|
# Private helpers
|
|
536
616
|
# ─────────────────────────────────────────────────────────────
|
|
537
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
|
+
|
|
538
623
|
def _headers(self, *, method: str, pathname: str, body: bytes | str | None) -> dict[str, str]:
|
|
539
624
|
return build_auth_headers(
|
|
540
625
|
self._api_key,
|
openbox_langgraph/config.py
CHANGED
|
@@ -14,10 +14,12 @@ from openbox_langgraph.errors import (
|
|
|
14
14
|
OpenBoxConfigError,
|
|
15
15
|
OpenBoxInsecureURLError,
|
|
16
16
|
OpenBoxNetworkError,
|
|
17
|
+
_raise_core_error,
|
|
17
18
|
)
|
|
18
19
|
from openbox_langgraph.identity import (
|
|
19
20
|
AgentIdentityConfig,
|
|
20
21
|
parse_optional_agent_identity_config,
|
|
22
|
+
parse_optional_workload_private_key,
|
|
21
23
|
)
|
|
22
24
|
from openbox_langgraph.types import DEFAULT_HITL_CONFIG, HITLConfig
|
|
23
25
|
|
|
@@ -193,6 +195,7 @@ class _GlobalConfigState:
|
|
|
193
195
|
governance_timeout: float = 30.0 # seconds
|
|
194
196
|
agent_did: str | None = None
|
|
195
197
|
agent_private_key: str | None = None
|
|
198
|
+
workload_private_key: str | None = field(default=None, repr=False)
|
|
196
199
|
|
|
197
200
|
def configure(
|
|
198
201
|
self,
|
|
@@ -200,12 +203,14 @@ class _GlobalConfigState:
|
|
|
200
203
|
api_key: str,
|
|
201
204
|
governance_timeout: float = 30.0,
|
|
202
205
|
agent_identity: AgentIdentityConfig | None = None,
|
|
206
|
+
workload_private_key: str | None = None,
|
|
203
207
|
) -> None:
|
|
204
208
|
self.api_url = api_url.rstrip("/")
|
|
205
209
|
self.api_key = api_key
|
|
206
210
|
self.governance_timeout = governance_timeout
|
|
207
211
|
self.agent_did = agent_identity.did if agent_identity else None
|
|
208
212
|
self.agent_private_key = agent_identity.private_key if agent_identity else None
|
|
213
|
+
self.workload_private_key = workload_private_key
|
|
209
214
|
|
|
210
215
|
def __repr__(self) -> str:
|
|
211
216
|
if self.api_key and len(self.api_key) > 8:
|
|
@@ -293,6 +298,41 @@ def _validate_api_key_with_server(
|
|
|
293
298
|
# ═══════════════════════════════════════════════════════════════════
|
|
294
299
|
|
|
295
300
|
|
|
301
|
+
def _validate_workload_identity_with_server(
|
|
302
|
+
api_url: str,
|
|
303
|
+
api_key: str,
|
|
304
|
+
timeout: float,
|
|
305
|
+
workload_private_key: str,
|
|
306
|
+
agent_identity: AgentIdentityConfig | None,
|
|
307
|
+
) -> None:
|
|
308
|
+
"""Let the base SDK bootstrap, exchange a token, and validate the active identity."""
|
|
309
|
+
from openbox_core.client import EvaluationClient
|
|
310
|
+
from openbox_core.errors import OpenBoxConfigError as CoreOpenBoxConfigError
|
|
311
|
+
from openbox_core.identity import AgentIdentity
|
|
312
|
+
|
|
313
|
+
from openbox_langgraph.client import _SDK_PACKAGE_VERSION
|
|
314
|
+
|
|
315
|
+
client = EvaluationClient(
|
|
316
|
+
api_url,
|
|
317
|
+
api_key,
|
|
318
|
+
timeout_seconds=timeout,
|
|
319
|
+
workload_private_key=workload_private_key,
|
|
320
|
+
identity=(
|
|
321
|
+
AgentIdentity.from_private_key(agent_identity.did, agent_identity.private_key)
|
|
322
|
+
if agent_identity
|
|
323
|
+
else None
|
|
324
|
+
),
|
|
325
|
+
sdk_version=_SDK_PACKAGE_VERSION,
|
|
326
|
+
sdk_engine="langgraph",
|
|
327
|
+
)
|
|
328
|
+
try:
|
|
329
|
+
client.validate_api_key()
|
|
330
|
+
except CoreOpenBoxConfigError as exc:
|
|
331
|
+
_raise_core_error(exc)
|
|
332
|
+
finally:
|
|
333
|
+
client.close()
|
|
334
|
+
|
|
335
|
+
|
|
296
336
|
def initialize(
|
|
297
337
|
api_url: str,
|
|
298
338
|
api_key: str,
|
|
@@ -300,6 +340,7 @@ def initialize(
|
|
|
300
340
|
validate: bool = True,
|
|
301
341
|
agent_did: str | None = None,
|
|
302
342
|
agent_private_key: str | None = None,
|
|
343
|
+
workload_private_key: str | None = None,
|
|
303
344
|
) -> None:
|
|
304
345
|
"""Initialize the OpenBox LangGraph SDK with credentials.
|
|
305
346
|
|
|
@@ -314,6 +355,8 @@ def initialize(
|
|
|
314
355
|
agent_did: Optional OpenBox agent DID. Falls back to `OPENBOX_AGENT_DID`.
|
|
315
356
|
agent_private_key: Optional raw Ed25519 private key seed. Falls back to
|
|
316
357
|
`OPENBOX_AGENT_PRIVATE_KEY`.
|
|
358
|
+
workload_private_key: PKCS8 PEM RSA key for IAM v3. Falls back to
|
|
359
|
+
`OPENBOX_LANGGRAPH_WORKLOAD_PRIVATE_KEY`, then `OPENBOX_WORKLOAD_PRIVATE_KEY`.
|
|
317
360
|
"""
|
|
318
361
|
import os
|
|
319
362
|
|
|
@@ -337,7 +380,17 @@ def initialize(
|
|
|
337
380
|
except OpenBoxConfigError:
|
|
338
381
|
raise
|
|
339
382
|
|
|
340
|
-
if
|
|
383
|
+
if workload_private_key is None:
|
|
384
|
+
workload_private_key = os.environ.get(
|
|
385
|
+
"OPENBOX_LANGGRAPH_WORKLOAD_PRIVATE_KEY", os.environ.get("OPENBOX_WORKLOAD_PRIVATE_KEY")
|
|
386
|
+
)
|
|
387
|
+
workload_private_key = parse_optional_workload_private_key(workload_private_key)
|
|
388
|
+
|
|
389
|
+
if validate and workload_private_key is not None:
|
|
390
|
+
_validate_workload_identity_with_server(
|
|
391
|
+
api_url.rstrip("/"), api_key, governance_timeout, workload_private_key, agent_identity
|
|
392
|
+
)
|
|
393
|
+
elif validate:
|
|
341
394
|
_validate_api_key_with_server(
|
|
342
395
|
api_url.rstrip("/"),
|
|
343
396
|
api_key,
|
|
@@ -350,6 +403,7 @@ def initialize(
|
|
|
350
403
|
api_key,
|
|
351
404
|
governance_timeout,
|
|
352
405
|
agent_identity,
|
|
406
|
+
workload_private_key,
|
|
353
407
|
)
|
|
354
408
|
|
|
355
409
|
_get_logger().info(f"OpenBox LangGraph SDK initialized with API URL: {api_url}")
|