openbox-langgraph-sdk-python 1.0.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.
@@ -17,7 +17,7 @@ Example:
17
17
  ... )
18
18
  """
19
19
 
20
- __version__ = "1.0.0"
20
+ __version__ = "1.1.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",
@@ -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 OpenBoxNetworkError as _CoreOpenBoxNetworkError
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 OpenBoxConfigError, OpenBoxNetworkError
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.0.0"
44
+ _SDK_PACKAGE_VERSION = "1.1.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
- - `GovernanceAPIError` / `OpenBoxNetworkError` (network-shaped failure) ->
118
- this SDK's `OpenBoxNetworkError`, same public exception the legacy path
119
- raises under fail_closed.
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, _CoreOpenBoxNetworkError) as e:
131
- raise OpenBoxNetworkError(str(e)) from e
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
- Single source of truth for the SDK's outbound governance requests.
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
- # `evaluate_event_sync` (sync middleware hooks) is unaffected either way.
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 the underlying HTTP clients."""
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
- return await _gate_evaluate(self._gate, event, self._on_api_error)
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 network failure so the caller can retry.
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 `/api/v1/governance/evaluate`.
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,
@@ -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 validate:
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}")
@@ -92,7 +92,9 @@ class LangGraphFrameworkAdapter:
92
92
 
93
93
  # ── Approval (RAISE-ONLY — never an inline blocking wait) ──────────────
94
94
 
95
- async def handle_approval(self, result: EvaluationResult) -> None:
95
+ async def handle_approval(
96
+ self, result: EvaluationResult, context: ActivityContext | None = None
97
+ ) -> None:
96
98
  """Async started-hook REQUIRE_APPROVAL -> raise, never await inline.
97
99
 
98
100
  The base ``HookRuntime._adecide_started`` treats a normal RETURN as
@@ -100,7 +102,8 @@ class LangGraphFrameworkAdapter:
100
102
  signal for a ``requires_approval()`` verdict, so the handler's outer
101
103
  catch/poll/retry loop drives the approval flow.
102
104
  """
103
- self._raise_pending_approval(result, self._store.current_activity_context())
105
+ ctx = context if context is not None else self._store.current_activity_context()
106
+ self._raise_pending_approval(result, ctx)
104
107
 
105
108
  def handle_approval_sync(
106
109
  self, result: EvaluationResult, *, context: ActivityContext | None = None
@@ -138,7 +141,9 @@ class LangGraphFrameworkAdapter:
138
141
 
139
142
  # ── Completed-hook telemetry (never undoes the operation) ──────────────
140
143
 
141
- def on_completed_hook_result(self, result: EvaluationResult) -> None:
144
+ def on_completed_hook_result(
145
+ self, result: EvaluationResult, context: ActivityContext | None = None
146
+ ) -> None:
142
147
  """Completed verdicts affect FUTURE execution only — the operation
143
148
  already ran. ``HookRuntime._after_completed`` has already marked the
144
149
  abort/halt flags on the base ``ContextStore`` for a stop-shaped
@@ -42,6 +42,7 @@ from openbox_core.runtime import OpenBoxRuntime
42
42
 
43
43
  from openbox_langgraph.config import GovernanceConfig
44
44
  from openbox_langgraph.errors import OpenBoxConfigError
45
+ from openbox_langgraph.identity import parse_optional_workload_private_key
45
46
  from openbox_langgraph.trace_context_registry import (
46
47
  TraceContextRegistry,
47
48
  get_context_store,
@@ -53,7 +54,7 @@ from openbox_langgraph.trace_context_registry import (
53
54
  CORE_ENV_PREFIX = "OPENBOX_LANGGRAPH"
54
55
  SDK_ENGINE = "langgraph"
55
56
  SDK_LANGUAGE = "python"
56
- SDK_PACKAGE_VERSION = "1.0.0"
57
+ SDK_PACKAGE_VERSION = "1.1.0"
57
58
 
58
59
  __all__ = [
59
60
  "CORE_ENV_PREFIX",
@@ -72,6 +73,7 @@ def create_core_runtime(
72
73
  governance_timeout: float | None = None,
73
74
  agent_did: str | None = None,
74
75
  agent_private_key: str | None = None,
76
+ workload_private_key: str | None = None,
75
77
  extra_ignored_urls: set[str] | None = None,
76
78
  ) -> OpenBoxRuntime:
77
79
  """Resolve base-SDK config and build an isolated ``OpenBoxRuntime``.
@@ -124,11 +126,15 @@ def create_core_runtime(
124
126
  agent_name=config.agent_name,
125
127
  agent_did=agent_did,
126
128
  agent_private_key=agent_private_key,
129
+ workload_private_key=workload_private_key,
127
130
  sdk_version=SDK_PACKAGE_VERSION,
128
131
  sdk_engine=SDK_ENGINE,
129
132
  sdk_language=SDK_LANGUAGE,
130
133
  validate=True,
131
134
  )
135
+ core_config.workload_private_key = parse_optional_workload_private_key(
136
+ core_config.workload_private_key
137
+ )
132
138
 
133
139
  from openbox_core.context import ContextStore
134
140
  from openbox_core.instrumentation.manager import InstrumentationManager
@@ -2,13 +2,23 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from typing import NoReturn
6
+
5
7
 
6
8
  class OpenBoxError(Exception):
7
9
  """Base class for all OpenBox SDK errors."""
8
10
 
9
11
 
10
12
  class OpenBoxAuthError(OpenBoxError):
11
- """Raised when the API key is invalid or unauthorized."""
13
+ """Raised when the API key or workload identity is invalid or unauthorized."""
14
+
15
+
16
+ class OpenBoxSigningError(OpenBoxAuthError):
17
+ """Identity proof rejected by Core, with its machine-readable reason code."""
18
+
19
+ def __init__(self, message: str, reason_code: str | None = None) -> None:
20
+ self.reason_code = reason_code
21
+ super().__init__(message)
12
22
 
13
23
 
14
24
  class OpenBoxConfigError(OpenBoxError):
@@ -23,6 +33,25 @@ class OpenBoxInsecureURLError(OpenBoxError):
23
33
  """Raised when an insecure HTTP URL is used for a non-localhost endpoint."""
24
34
 
25
35
 
36
+ def _raise_core_error(exc: Exception) -> NoReturn:
37
+ """Keep base-SDK auth/configuration failures in this SDK's public hierarchy."""
38
+ from openbox_core.errors import GovernanceAPIError as CoreGovernanceAPIError
39
+ from openbox_core.errors import OpenBoxAuthError as CoreOpenBoxAuthError
40
+ from openbox_core.errors import OpenBoxConfigError as CoreOpenBoxConfigError
41
+ from openbox_core.errors import OpenBoxNetworkError as CoreOpenBoxNetworkError
42
+ from openbox_core.errors import OpenBoxSigningError as CoreOpenBoxSigningError
43
+
44
+ if isinstance(exc, CoreOpenBoxSigningError):
45
+ raise OpenBoxSigningError(str(exc), exc.reason_code) from exc
46
+ if isinstance(exc, CoreOpenBoxAuthError):
47
+ raise OpenBoxAuthError(str(exc)) from exc
48
+ if isinstance(exc, (CoreOpenBoxNetworkError, CoreGovernanceAPIError)):
49
+ raise OpenBoxNetworkError(str(exc)) from exc
50
+ if isinstance(exc, CoreOpenBoxConfigError):
51
+ raise OpenBoxConfigError(str(exc)) from exc
52
+ raise exc
53
+
54
+
26
55
  class GovernanceBlockedError(OpenBoxError):
27
56
  """Raised when governance returns a BLOCK or HALT verdict.
28
57
 
@@ -46,6 +46,20 @@ class AgentIdentityConfig:
46
46
  AgentIdentityHeaders = dict[str, str]
47
47
 
48
48
 
49
+ def parse_optional_workload_private_key(private_key: str | None) -> str | None:
50
+ """Validate a workload key locally, including when startup validation is off."""
51
+ if private_key is None:
52
+ return None
53
+ from openbox_core.identity_okta import load_rsa_pkcs8_private_key
54
+
55
+ normalized = private_key.strip()
56
+ try:
57
+ load_rsa_pkcs8_private_key(normalized, key_label="workload_private_key")
58
+ except CoreOpenBoxConfigError as exc:
59
+ raise OpenBoxConfigError(str(exc)) from exc
60
+ return normalized
61
+
62
+
49
63
  def build_agent_identity_canonical_request(
50
64
  *,
51
65
  body_sha256: str,
@@ -19,6 +19,7 @@ from collections.abc import AsyncGenerator, AsyncIterator, Callable
19
19
  from dataclasses import dataclass, field
20
20
  from typing import Any, cast
21
21
 
22
+ from openbox_core.errors import OpenBoxConfigError as _CoreOpenBoxConfigError
22
23
  from openbox_langchain import (
23
24
  ActivityBridge,
24
25
  OpenBoxLangChainCoreAsyncCallbackHandler,
@@ -45,6 +46,7 @@ from openbox_langgraph.errors import (
45
46
  GovernanceBlockedError,
46
47
  GovernanceHaltError,
47
48
  GuardrailsValidationError,
49
+ _raise_core_error,
48
50
  )
49
51
  from openbox_langgraph.hitl import HITLPollParams, poll_until_decision
50
52
  from openbox_langgraph.tool_activity_binding import bind_tools_activity_scope, turn_metadata
@@ -388,6 +390,7 @@ class OpenBoxLangGraphHandler:
388
390
  governance_timeout=gc.governance_timeout,
389
391
  agent_did=gc.agent_did,
390
392
  agent_private_key=gc.agent_private_key,
393
+ workload_private_key=gc.workload_private_key,
391
394
  extra_ignored_urls={gc.api_url} if gc.api_url else None,
392
395
  )
393
396
  self._client = GovernanceClient(
@@ -398,6 +401,7 @@ class OpenBoxLangGraphHandler:
398
401
  agent_did=gc.agent_did,
399
402
  agent_private_key=gc.agent_private_key,
400
403
  gate=self._core_runtime.gate,
404
+ core_client=self._core_runtime.client,
401
405
  )
402
406
  # C1 — install condition == prepare condition: the pure-LangChain-Core
403
407
  # callback (installed per-turn in `_governed_config`) is only ever
@@ -921,6 +925,8 @@ class OpenBoxLangGraphHandler:
921
925
  _logger.info("[OpenBox] Approval granted, retrying ainvoke")
922
926
  self._reset_after_approval(workflow_id)
923
927
  final_output = await self._graph.ainvoke(input, config=cfg, **kwargs)
928
+ except _CoreOpenBoxConfigError as exc:
929
+ _raise_core_error(exc)
924
930
  except Exception as exc:
925
931
  hook_err = _extract_governance_blocked(exc)
926
932
  if hook_err is None or hook_err.verdict != "require_approval":
@@ -1006,6 +1012,8 @@ class OpenBoxLangGraphHandler:
1006
1012
  pre_screen_claim=pre_screen_claim,
1007
1013
  )
1008
1014
  yield event
1015
+ except _CoreOpenBoxConfigError as exc:
1016
+ _raise_core_error(exc)
1009
1017
  finally:
1010
1018
  # Outermost `finally` on an async generator fires on normal
1011
1019
  # exhaustion, an exception raised through the loop, OR the
@@ -1094,6 +1102,8 @@ class OpenBoxLangGraphHandler:
1094
1102
  workflow_started_sent=workflow_started_sent,
1095
1103
  )
1096
1104
  yield event
1105
+ except _CoreOpenBoxConfigError as exc:
1106
+ _raise_core_error(exc)
1097
1107
  finally:
1098
1108
  # See astream_governed's identical finally for why this covers
1099
1109
  # normal exhaustion, mid-stream exceptions, AND an abandoned
@@ -1981,6 +1991,7 @@ def create_openbox_graph_handler(
1981
1991
  sqlalchemy_engine: Any = None,
1982
1992
  agent_did: str | None = None,
1983
1993
  agent_private_key: str | None = None,
1994
+ workload_private_key: str | None = None,
1984
1995
  **handler_kwargs: Any,
1985
1996
  ) -> OpenBoxLangGraphHandler:
1986
1997
  """Create a fully configured `OpenBoxLangGraphHandler` wrapping a compiled LangGraph graph.
@@ -2000,6 +2011,8 @@ def create_openbox_graph_handler(
2000
2011
  agent_did: Optional OpenBox agent DID. Falls back to `OPENBOX_AGENT_DID`.
2001
2012
  agent_private_key: Optional raw Ed25519 private key seed. Falls back to
2002
2013
  `OPENBOX_AGENT_PRIVATE_KEY`.
2014
+ workload_private_key: PKCS8 PEM RSA key for IAM v3. Falls back to
2015
+ `OPENBOX_LANGGRAPH_WORKLOAD_PRIVATE_KEY`, then `OPENBOX_WORKLOAD_PRIVATE_KEY`.
2003
2016
  **handler_kwargs: Additional keyword arguments forwarded to
2004
2017
  `OpenBoxLangGraphHandlerOptions`.
2005
2018
 
@@ -2023,6 +2036,7 @@ def create_openbox_graph_handler(
2023
2036
  validate=validate,
2024
2037
  agent_did=agent_did,
2025
2038
  agent_private_key=agent_private_key,
2039
+ workload_private_key=workload_private_key,
2026
2040
  )
2027
2041
 
2028
2042
  options = OpenBoxLangGraphHandlerOptions(
@@ -35,10 +35,13 @@ from typing import Any
35
35
  from openbox_core.context import activity_scope
36
36
  from openbox_core.contracts.context import ActivityContext
37
37
  from openbox_core.contracts.otel_spans import HookType
38
+ from openbox_core.errors import OpenBoxConfigError as _CoreOpenBoxConfigError
38
39
  from openbox_core.hooks.events import resolve_context
39
40
  from openbox_core.hooks.preflight import HookRuntime
40
41
  from openbox_core.otel.trace_context import raw_trace_id
41
42
 
43
+ from openbox_langgraph.errors import _raise_core_error
44
+
42
45
  __all__ = ["LangGraphHookRuntime"]
43
46
 
44
47
  # Bound on in-flight pins. A pin is normally cleared at completed (or when
@@ -139,6 +142,9 @@ class LangGraphHookRuntime(HookRuntime):
139
142
  proceed = super().preflight(
140
143
  span, hook_type=hook_type, identifier=identifier, fields=fields
141
144
  )
145
+ except _CoreOpenBoxConfigError as exc:
146
+ self._unpin(key)
147
+ _raise_core_error(exc)
142
148
  except BaseException:
143
149
  # Blocked / halted / approval-rejected: no completed callback will
144
150
  # follow, so drop the pin rather than leak it.
@@ -161,6 +167,9 @@ class LangGraphHookRuntime(HookRuntime):
161
167
  proceed = await super().apreflight(
162
168
  span, hook_type=hook_type, identifier=identifier, fields=fields
163
169
  )
170
+ except _CoreOpenBoxConfigError as exc:
171
+ self._unpin(key)
172
+ _raise_core_error(exc)
164
173
  except BaseException:
165
174
  self._unpin(key)
166
175
  raise
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: openbox-langgraph-sdk-python
3
- Version: 1.0.0
3
+ Version: 1.1.0
4
4
  Summary: OpenBox governance and observability SDK for LangGraph
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -10,7 +10,7 @@ Requires-Dist: langchain-core>=1.3.3
10
10
  Requires-Dist: langgraph>=0.2.0
11
11
  Requires-Dist: langsmith>=0.8.18
12
12
  Requires-Dist: openbox-langchain-sdk-python>=1.0.0
13
- Requires-Dist: openbox-sdk-python>=1.0.1
13
+ Requires-Dist: openbox-sdk-python>=1.4.0
14
14
  Requires-Dist: opentelemetry-api>=1.20.0
15
15
  Requires-Dist: opentelemetry-instrumentation-asyncpg>=0.41b0
16
16
  Requires-Dist: opentelemetry-instrumentation-httpx>=0.41b0
@@ -136,17 +136,14 @@ uv add openbox-langgraph-sdk-python
136
136
 
137
137
  ### 1. Get your agent credentials
138
138
 
139
- Sign in to [dashboard.openbox.ai](https://dashboard.openbox.ai), create an agent called `"MyAgent"`, and copy the agent API key plus its DID credentials.
140
-
141
- New OpenBox agents have DID signing enabled by default. Keep the private key secret and load it from your environment.
139
+ Sign in to [dashboard.openbox.ai](https://dashboard.openbox.ai), create an agent called `"MyAgent"`, and copy its API key. For an IAM v3 workload-enabled agent, also load the PKCS8 PEM RSA private key registered for its active Keycloak service account. Keep the key secret.
142
140
 
143
141
  ### 2. Set environment variables
144
142
 
145
143
  ```bash
146
144
  export OPENBOX_URL="https://core.openbox.ai"
147
145
  export OPENBOX_API_KEY="obx_live_..."
148
- export OPENBOX_AGENT_DID="did:aip:..."
149
- export OPENBOX_AGENT_PRIVATE_KEY="..."
146
+ export OPENBOX_WORKLOAD_PRIVATE_KEY="$(cat /path/to/workload-private-key.pem)"
150
147
  ```
151
148
 
152
149
  ### 3. Wrap your graph
@@ -181,6 +178,50 @@ asyncio.run(main())
181
178
 
182
179
  That's it. Your agent now sends governance events to OpenBox on every tool call, LLM prompt, HTTP request, and database query.
183
180
 
181
+ ### IAM v3 workload identity
182
+
183
+ IAM v3 is provided by `openbox-sdk-python>=1.4.0`. The existing
184
+ `openbox-langchain-sdk-python` dependency remains at `>=1.0.0`; its callbacks
185
+ use the same base runtime as the LangGraph handler and operation hooks.
186
+
187
+ You can pass the workload key explicitly instead of setting an environment variable:
188
+
189
+ ```python
190
+ governed = create_openbox_graph_handler(
191
+ graph=agent,
192
+ api_url=os.environ["OPENBOX_URL"],
193
+ api_key=os.environ["OPENBOX_API_KEY"],
194
+ workload_private_key=os.environ["OPENBOX_WORKLOAD_PRIVATE_KEY"],
195
+ )
196
+ ```
197
+
198
+ Key resolution is explicit argument, then `OPENBOX_LANGGRAPH_WORKLOAD_PRIVATE_KEY`,
199
+ then `OPENBOX_WORKLOAD_PRIVATE_KEY`. The value is the PEM contents, not a file path.
200
+ The Python base SDK selects workload authentication from this key; no
201
+ `OPENBOX_AGENT_IDENTITY_METHOD` setting is needed for IAM v3.
202
+
203
+ The base SDK fetches `/api/v3/auth/bootstrap`, exchanges a signed `private_key_jwt`
204
+ for a short-lived Keycloak token, and sends the API key plus
205
+ `X-OpenBox-Workload-Token` on v3 validation, evaluation, and approval requests.
206
+ It owns token caching and refresh for OpenBox-, Okta-, and Entra-managed identities.
207
+ Lifecycle callbacks, hooks, synchronous/raw evaluations, and approval polling share
208
+ the handler's runtime client. A standalone `GovernanceClient` also accepts
209
+ `workload_private_key`; call `await client.close()` when finished with it.
210
+
211
+ Authentication and signing failures raise `OpenBoxAuthError` or its subclass
212
+ `OpenBoxSigningError` even with `on_api_error="fail_open"`. The signing error retains
213
+ Core's `reason_code`. Invalid bootstrap metadata raises `OpenBoxConfigError`, and
214
+ bootstrap/token service outages raise `OpenBoxNetworkError`; these do not become
215
+ implicit ALLOW or pending approval results. `validate=False` skips the startup
216
+ network check only: the key is still validated locally, and runtime requests
217
+ still authenticate.
218
+
219
+ The base SDK preserves rolling-upgrade compatibility: bootstrap HTTP 404, or
220
+ HTTP 409 with `workload_identity_unavailable`, retains the configured legacy route.
221
+ Other bootstrap failures do not downgrade. Existing DID signing remains available
222
+ through `agent_did`/`agent_private_key` or `OPENBOX_AGENT_DID`/`OPENBOX_AGENT_PRIVATE_KEY`.
223
+ Without identity credentials, the existing API-key-only behavior is preserved.
224
+
184
225
  ### Try it locally (included test agent)
185
226
 
186
227
  The repository includes a runnable LangGraph test agent under `test-agent/`.
@@ -207,6 +248,7 @@ See `test-agent/README.md` for setup and run instructions.
207
248
  | `api_key` | `str` | **required** | API key (`obx_live_*` or `obx_test_*`) |
208
249
  | `agent_did` | `str` | `OPENBOX_AGENT_DID` | Agent DID used to sign governance requests |
209
250
  | `agent_private_key` | `str` | `OPENBOX_AGENT_PRIVATE_KEY` | Base64 raw Ed25519 private key seed for the agent DID |
251
+ | `workload_private_key` | `str` | `OPENBOX_LANGGRAPH_WORKLOAD_PRIVATE_KEY`, then `OPENBOX_WORKLOAD_PRIVATE_KEY` | PKCS8 PEM RSA key for IAM v3 workload authentication |
210
252
  | `agent_name` | `str` | `None` | Agent name as configured in the dashboard |
211
253
  | `validate` | `bool` | `True` | Validate API key against server on startup |
212
254
  | `on_api_error` | `str` | `"fail_open"` | `"fail_open"` (allow on error) or `"fail_closed"` (block on error) |
@@ -0,0 +1,23 @@
1
+ openbox_langgraph/__init__.py,sha256=J-3oOgnRjF5ixC8r90vthDAVdotGI-fyg84byXvl4xQ,4142
2
+ openbox_langgraph/activity_context_binding.py,sha256=kI_Qu7R0mjM2js50sN3H-9CNWjzF8tI7Xg9afvnQeHo,4789
3
+ openbox_langgraph/client.py,sha256=Asq9iw6BcazzIgRpyqP4KJOFris2ferOoz0u63EAtNo,26835
4
+ openbox_langgraph/config.py,sha256=XSbd5RMEsf1VWFQkCx7hJceTu_EcSs_uTMYL_LIybRE,17183
5
+ openbox_langgraph/core_adapter.py,sha256=xv--ptTjoeAD7bo7IvJOhP_RRnKTkoAr06VY945yYBE,11418
6
+ openbox_langgraph/core_events.py,sha256=UXS9iWFPBLcClbfMa9MjaJDp4ZjMrm49x_oqX3TAZQg,6237
7
+ openbox_langgraph/core_runtime.py,sha256=NRcjbbYnE-1sOaFM_rcmNpcAkufqTpRbn1L2WB0GXWk,8600
8
+ openbox_langgraph/errors.py,sha256=Xry_Ir6U2wmKerluIJ18eHPzuE5YCq0Ib1uY58raXro,5137
9
+ openbox_langgraph/hitl.py,sha256=p8Xyxp9vvIBciumghM2RGJ53deIIwbufLRmw-PPLteU,2751
10
+ openbox_langgraph/identity.py,sha256=t4jGkUNGz4OwcGXrWMgy5jVriEYJwCE_509Yi-MKaHY,5965
11
+ openbox_langgraph/langgraph_handler.py,sha256=Ibp4wcBznlAh4qtH2nNiWGzCUX4YFZhrJF7pTSpLZaA,106043
12
+ openbox_langgraph/langgraph_hook_runtime.py,sha256=FVoTvay5ZJAI8iNxRCdPNEeldi7l50AMRGP_vmWU6G4,8757
13
+ openbox_langgraph/otel_setup.py,sha256=Qk8GiXZ1f4WlSjT1_Id48dRfoZGkP_A0VCQFVX2wysU,1584
14
+ openbox_langgraph/span_processor.py,sha256=MfsHG4La0P-G1mo2GCgFqU-al5iljExTAA1bSrCRtJA,14045
15
+ openbox_langgraph/tool_activity_binding.py,sha256=MBql-XoqRkooy6vwBhi8kmnWnmJIIuIXRyWqj1lUqmo,14918
16
+ openbox_langgraph/trace_context_registry.py,sha256=fcU-WXt-8VLsyd8irJ1feQQCeLiGFSK8oNN-zfbCcPM,9583
17
+ openbox_langgraph/tracing.py,sha256=24CIs5hgnK2nRH0vF7g96L3fuxyP66w24Oe0sJbBCc8,7299
18
+ openbox_langgraph/types.py,sha256=zUrk44-1PBWirW7bPPrsLcMNzJSVYCdpzPuu4iBwIXs,26368
19
+ openbox_langgraph/verdict_handler.py,sha256=YmZjBxzOa2RBbkF13xDCPUAGzz7m1hwdMErwOVDl2WM,8323
20
+ openbox_langgraph_sdk_python-1.1.0.dist-info/METADATA,sha256=sl6QbrHNXa8u8J0SRYzXb-3JReuTwVyl1BlD9hk8rHA,24140
21
+ openbox_langgraph_sdk_python-1.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
22
+ openbox_langgraph_sdk_python-1.1.0.dist-info/licenses/LICENSE,sha256=bPyy7QFDMnA5r6asaR3iD9rQvpjz5cUFk1ouHCuBYIY,1071
23
+ openbox_langgraph_sdk_python-1.1.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.30.1
2
+ Generator: hatchling 1.32.4
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -1,23 +0,0 @@
1
- openbox_langgraph/__init__.py,sha256=A2qUhwWwxsySPtYQ-oRp8G06nCfjXKODBmz7ZP8dkQw,4090
2
- openbox_langgraph/activity_context_binding.py,sha256=kI_Qu7R0mjM2js50sN3H-9CNWjzF8tI7Xg9afvnQeHo,4789
3
- openbox_langgraph/client.py,sha256=awaj-q-ImFCfdJQAxMkSWUGeI_dN6TMe6pe1J3Nhojw,22834
4
- openbox_langgraph/config.py,sha256=-eZuX59ydO9XkWMKSOkZGfuYkXnUswFdefoNpJOt3SI,15120
5
- openbox_langgraph/core_adapter.py,sha256=y6oVTuq6AZvAv6EREC6HublU8Pi2si8FSAyK-ky6knc,11256
6
- openbox_langgraph/core_events.py,sha256=UXS9iWFPBLcClbfMa9MjaJDp4ZjMrm49x_oqX3TAZQg,6237
7
- openbox_langgraph/core_runtime.py,sha256=bVsm_9o7rI6boAL5YnQ5kpjpHWj8lrZDML__dmv3fi4,8306
8
- openbox_langgraph/errors.py,sha256=2FLK2_h-ObNZ-r3jPZJQR0fU5WMBoEE_0FPOxL5BLlA,3806
9
- openbox_langgraph/hitl.py,sha256=p8Xyxp9vvIBciumghM2RGJ53deIIwbufLRmw-PPLteU,2751
10
- openbox_langgraph/identity.py,sha256=kFP2AwUyCrmp26kE7J-_Cajksef2x4YVFf_WSCpJUTE,5436
11
- openbox_langgraph/langgraph_handler.py,sha256=kMHs1j9Pn-EFIT66706cmO_E_172Ve-9iXX3YHtyQE4,105318
12
- openbox_langgraph/langgraph_hook_runtime.py,sha256=m3UXOKmsAz0qCiYyAsZvJEs1EecIrbxblPivJtlTt14,8401
13
- openbox_langgraph/otel_setup.py,sha256=Qk8GiXZ1f4WlSjT1_Id48dRfoZGkP_A0VCQFVX2wysU,1584
14
- openbox_langgraph/span_processor.py,sha256=MfsHG4La0P-G1mo2GCgFqU-al5iljExTAA1bSrCRtJA,14045
15
- openbox_langgraph/tool_activity_binding.py,sha256=MBql-XoqRkooy6vwBhi8kmnWnmJIIuIXRyWqj1lUqmo,14918
16
- openbox_langgraph/trace_context_registry.py,sha256=fcU-WXt-8VLsyd8irJ1feQQCeLiGFSK8oNN-zfbCcPM,9583
17
- openbox_langgraph/tracing.py,sha256=24CIs5hgnK2nRH0vF7g96L3fuxyP66w24Oe0sJbBCc8,7299
18
- openbox_langgraph/types.py,sha256=zUrk44-1PBWirW7bPPrsLcMNzJSVYCdpzPuu4iBwIXs,26368
19
- openbox_langgraph/verdict_handler.py,sha256=YmZjBxzOa2RBbkF13xDCPUAGzz7m1hwdMErwOVDl2WM,8323
20
- openbox_langgraph_sdk_python-1.0.0.dist-info/METADATA,sha256=6cO6xSCgLUcoltXiOruip3gVuKRCakgB_1DabcbZTEM,21647
21
- openbox_langgraph_sdk_python-1.0.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
22
- openbox_langgraph_sdk_python-1.0.0.dist-info/licenses/LICENSE,sha256=bPyy7QFDMnA5r6asaR3iD9rQvpjz5cUFk1ouHCuBYIY,1071
23
- openbox_langgraph_sdk_python-1.0.0.dist-info/RECORD,,