openbox-langgraph-sdk-python 0.2.0__py3-none-any.whl → 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- openbox_langgraph/__init__.py +3 -0
- openbox_langgraph/activity_context_binding.py +126 -0
- openbox_langgraph/client.py +170 -47
- openbox_langgraph/config.py +31 -0
- openbox_langgraph/core_adapter.py +223 -0
- openbox_langgraph/core_events.py +141 -0
- openbox_langgraph/core_runtime.py +169 -0
- openbox_langgraph/identity.py +37 -27
- openbox_langgraph/langgraph_handler.py +824 -300
- openbox_langgraph/langgraph_hook_runtime.py +201 -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.0.0.dist-info}/METADATA +10 -7
- openbox_langgraph_sdk_python-1.0.0.dist-info/RECORD +23 -0
- {openbox_langgraph_sdk_python-0.2.0.dist-info → openbox_langgraph_sdk_python-1.0.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.0.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.0.0"
|
|
21
|
+
|
|
20
22
|
from openbox_langgraph.client import GovernanceClient, build_auth_headers
|
|
21
23
|
from openbox_langgraph.config import (
|
|
22
24
|
GovernanceConfig,
|
|
@@ -115,6 +117,7 @@ __all__ = [
|
|
|
115
117
|
"WorkflowEventType",
|
|
116
118
|
"WorkflowSpanBuffer",
|
|
117
119
|
"WorkflowSpanProcessor",
|
|
120
|
+
"__version__",
|
|
118
121
|
"build_agent_identity_canonical_request",
|
|
119
122
|
"build_auth_headers",
|
|
120
123
|
"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,11 +5,17 @@ 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
|
-
|
|
11
|
+
from openbox_core.client import check_expiration
|
|
12
|
+
from openbox_core.contracts.results import EvaluationResult
|
|
13
|
+
from openbox_core.contracts.results import Verdict as _CoreVerdict
|
|
14
|
+
from openbox_core.errors import ContractError as _CoreContractError
|
|
15
|
+
from openbox_core.errors import GovernanceAPIError as _CoreGovernanceAPIError
|
|
16
|
+
from openbox_core.errors import OpenBoxNetworkError as _CoreOpenBoxNetworkError
|
|
17
|
+
|
|
18
|
+
from openbox_langgraph.core_events import to_envelope
|
|
13
19
|
from openbox_langgraph.errors import OpenBoxConfigError, OpenBoxNetworkError
|
|
14
20
|
from openbox_langgraph.identity import (
|
|
15
21
|
AgentIdentityConfig,
|
|
@@ -25,7 +31,107 @@ from openbox_langgraph.types import (
|
|
|
25
31
|
to_server_event_type,
|
|
26
32
|
)
|
|
27
33
|
|
|
28
|
-
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from openbox_core.gate import GovernanceGate
|
|
36
|
+
|
|
37
|
+
_SDK_PACKAGE_VERSION = "1.0.0"
|
|
38
|
+
_SDK_IDENTIFIER = f"openbox-langgraph-python-v{_SDK_PACKAGE_VERSION}"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _network_fallback_result(on_api_error: str, msg: str) -> GovernanceVerdictResponse | None:
|
|
42
|
+
"""Apply the `on_api_error` policy to a NETWORK/transport failure.
|
|
43
|
+
|
|
44
|
+
This is the ONLY place a `GovernanceClient` verdict call may return
|
|
45
|
+
`None` — it corresponds exactly to a client-synthesized fallback
|
|
46
|
+
(`EvaluationResult.fallback_allow`): `verdict=ALLOW`, `fallback_used=True`,
|
|
47
|
+
and an EMPTY `raw` dict (nothing was ever parsed from a real body).
|
|
48
|
+
|
|
49
|
+
A response BODY that happens to be `{"verdict": "block",
|
|
50
|
+
"fallback_used": true}` never reaches this function — it is parsed by
|
|
51
|
+
`_verdict_from_response_data` instead, whose `raw` is always the
|
|
52
|
+
non-empty parsed dict, so the None-collapse below can never fire for it
|
|
53
|
+
and the BLOCK is enforced normally.
|
|
54
|
+
|
|
55
|
+
`msg` is the caller's fully-formatted message (kept caller-side so the
|
|
56
|
+
two distinct failure messages — "Governance API error: HTTP {status}"
|
|
57
|
+
for a bad response, "Governance API unreachable: {e}" for a raised
|
|
58
|
+
exception — stay exactly as they were before this translation layer).
|
|
59
|
+
"""
|
|
60
|
+
if on_api_error == "fail_closed":
|
|
61
|
+
raise OpenBoxNetworkError(msg)
|
|
62
|
+
result = EvaluationResult.fallback_allow(msg)
|
|
63
|
+
return _collapse_client_synthesized_fallback(result)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _verdict_from_response_data(data: dict[str, Any]) -> GovernanceVerdictResponse:
|
|
67
|
+
"""Parse a real HTTP response body into a `GovernanceVerdictResponse`.
|
|
68
|
+
|
|
69
|
+
Routed through the base SDK's `EvaluationResult.from_dict` so `raw` is
|
|
70
|
+
always the full parsed body — the non-empty `raw` is exactly what keeps
|
|
71
|
+
`_collapse_client_synthesized_fallback` from ever mistaking a real
|
|
72
|
+
(even oddly-shaped) Core response for a client-side fallback.
|
|
73
|
+
"""
|
|
74
|
+
return GovernanceVerdictResponse.from_result(EvaluationResult.from_dict(data))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _collapse_client_synthesized_fallback(
|
|
78
|
+
result: EvaluationResult,
|
|
79
|
+
) -> GovernanceVerdictResponse | None:
|
|
80
|
+
"""Return `None` ONLY for a client-synthesized fail-open fallback.
|
|
81
|
+
|
|
82
|
+
The three-part discriminator matches `EvaluationResult.fallback_allow`
|
|
83
|
+
exactly and nothing else: `fallback_used=True` AND `verdict is ALLOW` AND
|
|
84
|
+
`raw` is empty (no real body was ever parsed). A response body that
|
|
85
|
+
happens to carry `fallback_used: true` alongside a blocking verdict, or
|
|
86
|
+
alongside ALLOW but WITH a real (non-empty) body, is a real Core response
|
|
87
|
+
and must be returned as a `GovernanceVerdictResponse` so callers enforce
|
|
88
|
+
it — never silently collapsed to `None`/implicit-ALLOW.
|
|
89
|
+
"""
|
|
90
|
+
if result.fallback_used and result.verdict is _CoreVerdict.ALLOW and not result.raw:
|
|
91
|
+
return None
|
|
92
|
+
return GovernanceVerdictResponse.from_result(result)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
async def _gate_evaluate(
|
|
96
|
+
gate: GovernanceGate, event: LangChainGovernanceEvent, on_api_error: str
|
|
97
|
+
) -> GovernanceVerdictResponse | None:
|
|
98
|
+
"""Evaluate one lifecycle event through the base SDK's strict gate.
|
|
99
|
+
|
|
100
|
+
The single translation seam between `gate.aevaluate`'s base-SDK contract
|
|
101
|
+
(`EvaluationResult`, `openbox_core` exceptions) and this SDK's own
|
|
102
|
+
(`GovernanceVerdictResponse | None`, `openbox_langgraph.errors`) — every
|
|
103
|
+
gate-routed call site in `evaluate_event` goes through this function so
|
|
104
|
+
the translation is defined exactly once. Outcome policy (mirrors the legacy
|
|
105
|
+
httpx path so the wired transport is behaviourally interchangeable):
|
|
106
|
+
|
|
107
|
+
- `EvaluationResult.fallback_allow()` (client-synthesized fail-open on a
|
|
108
|
+
NETWORK error): collapsed to `None` via the same discriminator used for
|
|
109
|
+
the legacy path, so the pre-screen-`None` -> callback re-evaluation ->
|
|
110
|
+
PII-redaction flow keeps firing regardless of which transport produced it.
|
|
111
|
+
- `ContractError` (a malformed envelope — a bug in THIS SDK's own
|
|
112
|
+
event->envelope mapping, raised pre-network by the strict gate, never
|
|
113
|
+
from a Core response): ALWAYS a fail-open telemetry-drop (`None`),
|
|
114
|
+
independent of `on_api_error`. Enforcing fail_closed here would let an
|
|
115
|
+
SDK-side mapping defect block a user's graph for a reason their OWN policy
|
|
116
|
+
never produced — strictly worse than dropping one governance event.
|
|
117
|
+
- `GovernanceAPIError` / `OpenBoxNetworkError` (network-shaped failure) ->
|
|
118
|
+
this SDK's `OpenBoxNetworkError`, same public exception the legacy path
|
|
119
|
+
raises under fail_closed.
|
|
120
|
+
- Any OTHER exception (e.g. a malformed Core 200 body the base parser
|
|
121
|
+
cannot decode) is a transport-shaped fault, NOT a governance verdict:
|
|
122
|
+
routed through `_network_fallback_result` so fail_open returns `None`
|
|
123
|
+
(never crash the graph on a Core hiccup) and fail_closed raises
|
|
124
|
+
`OpenBoxNetworkError` — matching the legacy httpx catch-all exactly.
|
|
125
|
+
"""
|
|
126
|
+
try:
|
|
127
|
+
result = await gate.aevaluate(to_envelope(event))
|
|
128
|
+
except _CoreContractError:
|
|
129
|
+
return None
|
|
130
|
+
except (_CoreGovernanceAPIError, _CoreOpenBoxNetworkError) as e:
|
|
131
|
+
raise OpenBoxNetworkError(str(e)) from e
|
|
132
|
+
except Exception as e:
|
|
133
|
+
return _network_fallback_result(on_api_error, f"Governance gate error: {e}")
|
|
134
|
+
return _collapse_client_synthesized_fallback(result)
|
|
29
135
|
|
|
30
136
|
|
|
31
137
|
def build_auth_headers(
|
|
@@ -38,13 +144,13 @@ def build_auth_headers(
|
|
|
38
144
|
) -> dict[str, str]:
|
|
39
145
|
"""Build standard auth headers for governance API calls.
|
|
40
146
|
|
|
41
|
-
Single source of truth
|
|
147
|
+
Single source of truth for the SDK's outbound governance requests.
|
|
42
148
|
"""
|
|
43
149
|
headers = {
|
|
44
150
|
"Authorization": f"Bearer {api_key}",
|
|
45
151
|
"Content-Type": "application/json",
|
|
46
|
-
"User-Agent": f"OpenBox-LangGraph-SDK/{
|
|
47
|
-
"X-OpenBox-SDK-Version":
|
|
152
|
+
"User-Agent": f"OpenBox-LangGraph-SDK/{_SDK_IDENTIFIER}",
|
|
153
|
+
"X-OpenBox-SDK-Version": _SDK_IDENTIFIER,
|
|
48
154
|
}
|
|
49
155
|
if agent_identity:
|
|
50
156
|
if method is None or pathname is None:
|
|
@@ -87,6 +193,7 @@ class GovernanceClient:
|
|
|
87
193
|
on_api_error: str = "fail_open",
|
|
88
194
|
agent_did: str | None = None,
|
|
89
195
|
agent_private_key: str | None = None,
|
|
196
|
+
gate: GovernanceGate | None = None,
|
|
90
197
|
) -> None:
|
|
91
198
|
self._api_url = api_url.rstrip("/")
|
|
92
199
|
self._api_key = api_key
|
|
@@ -98,9 +205,19 @@ class GovernanceClient:
|
|
|
98
205
|
did=agent_did,
|
|
99
206
|
private_key=agent_private_key,
|
|
100
207
|
)
|
|
208
|
+
# Optional base-SDK gate. When wired (by the handler, from a core
|
|
209
|
+
# runtime built off the SAME api_url/api_key/timeout/on_api_error),
|
|
210
|
+
# `evaluate_event`'s ASYNC path routes lifecycle events through it
|
|
211
|
+
# instead of this client's own httpx transport — see `evaluate_event`.
|
|
212
|
+
# `None` (the default) preserves the exact legacy transport/serialization
|
|
213
|
+
# for every existing caller that constructs a bare `GovernanceClient()`.
|
|
214
|
+
# `evaluate_event_sync` (sync middleware hooks) is unaffected either way.
|
|
215
|
+
self._gate = gate
|
|
101
216
|
# Deduplication: prevent sending the same (activity_id, event_type) twice
|
|
102
217
|
# within the same workflow run. Keyed by (workflow_id, run_id) so it resets
|
|
103
|
-
# automatically on each new ainvoke() call.
|
|
218
|
+
# automatically on each new ainvoke() call. Shared by every evaluate_event
|
|
219
|
+
# call site regardless of which transport (gate or legacy httpx) is active
|
|
220
|
+
# for a given call — dedup is a client-level concern, not a transport one.
|
|
104
221
|
self._dedup_run: tuple[str, str] | None = None
|
|
105
222
|
self._dedup_sent: set[tuple[str, str]] = set()
|
|
106
223
|
|
|
@@ -186,13 +303,23 @@ class GovernanceClient:
|
|
|
186
303
|
"""Send a governance event to OpenBox Core and return the verdict.
|
|
187
304
|
|
|
188
305
|
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
|
|
306
|
+
Silently drops duplicate (activity_id, event_type) pairs within the same run
|
|
307
|
+
— this de-dup pre-check runs BEFORE either transport below, so it applies
|
|
308
|
+
identically whether a `gate` is wired or not.
|
|
309
|
+
|
|
310
|
+
When a `gate` was supplied at construction (see `__init__`), the event is
|
|
311
|
+
routed through the base SDK's `EventEnvelope` + `GovernanceGate.aevaluate`
|
|
312
|
+
instead of this client's own httpx transport — see `_gate_evaluate`.
|
|
313
|
+
Overriding `evaluate_event` in a subclass (e.g. the golden-fixture
|
|
314
|
+
harness's `RecordingGovernanceClient`) still fully intercepts either way,
|
|
315
|
+
since the branch lives inside THIS method, never at a call site.
|
|
190
316
|
|
|
191
317
|
Args:
|
|
192
318
|
event: The governance event payload to evaluate.
|
|
193
319
|
|
|
194
320
|
Raises:
|
|
195
|
-
OpenBoxNetworkError: On network failure when `on_api_error` is `fail_closed
|
|
321
|
+
OpenBoxNetworkError: On network failure when `on_api_error` is `fail_closed`
|
|
322
|
+
(from either transport).
|
|
196
323
|
"""
|
|
197
324
|
server_event_type = to_server_event_type(event.event_type)
|
|
198
325
|
if event.activity_id and self._is_duplicate(
|
|
@@ -205,18 +332,22 @@ class GovernanceClient:
|
|
|
205
332
|
)
|
|
206
333
|
return None
|
|
207
334
|
|
|
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
335
|
if os.environ.get("OPENBOX_DEBUG") == "1":
|
|
214
336
|
import json
|
|
215
337
|
|
|
216
338
|
print(
|
|
217
|
-
f"[OpenBox Debug] governance request:
|
|
339
|
+
f"[OpenBox Debug] governance request: "
|
|
340
|
+
f"{json.dumps(event.to_dict(), indent=2, default=str)}"
|
|
218
341
|
)
|
|
219
342
|
|
|
343
|
+
if self._gate is not None:
|
|
344
|
+
return await _gate_evaluate(self._gate, event, self._on_api_error)
|
|
345
|
+
|
|
346
|
+
payload = event.to_dict()
|
|
347
|
+
payload["event_type"] = server_event_type
|
|
348
|
+
payload["task_queue"] = event.task_queue or "langgraph"
|
|
349
|
+
payload["source"] = "workflow-telemetry"
|
|
350
|
+
|
|
220
351
|
try:
|
|
221
352
|
client = self._get_client()
|
|
222
353
|
body = _json_body(payload)
|
|
@@ -231,21 +362,19 @@ class GovernanceClient:
|
|
|
231
362
|
)
|
|
232
363
|
|
|
233
364
|
if not response.is_success:
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
return None
|
|
365
|
+
return _network_fallback_result(
|
|
366
|
+
self._on_api_error, f"Governance API error: HTTP {response.status_code}"
|
|
367
|
+
)
|
|
238
368
|
|
|
239
369
|
data = response.json()
|
|
240
|
-
return
|
|
370
|
+
return _verdict_from_response_data(data)
|
|
241
371
|
|
|
242
372
|
except OpenBoxNetworkError:
|
|
243
373
|
raise
|
|
244
374
|
except Exception as e:
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
return None
|
|
375
|
+
return _network_fallback_result(
|
|
376
|
+
self._on_api_error, f"Governance API unreachable: {e}"
|
|
377
|
+
)
|
|
249
378
|
|
|
250
379
|
def evaluate_event_sync(
|
|
251
380
|
self, event: LangChainGovernanceEvent
|
|
@@ -288,21 +417,19 @@ class GovernanceClient:
|
|
|
288
417
|
)
|
|
289
418
|
|
|
290
419
|
if not response.is_success:
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
return None
|
|
420
|
+
return _network_fallback_result(
|
|
421
|
+
self._on_api_error, f"Governance API error: HTTP {response.status_code}"
|
|
422
|
+
)
|
|
295
423
|
|
|
296
424
|
data = response.json()
|
|
297
|
-
return
|
|
425
|
+
return _verdict_from_response_data(data)
|
|
298
426
|
|
|
299
427
|
except OpenBoxNetworkError:
|
|
300
428
|
raise
|
|
301
429
|
except Exception as e:
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
return None
|
|
430
|
+
return _network_fallback_result(
|
|
431
|
+
self._on_api_error, f"Governance API unreachable: {e}"
|
|
432
|
+
)
|
|
306
433
|
|
|
307
434
|
async def poll_approval(self, params: ApprovalPollParams) -> ApprovalResponse | None:
|
|
308
435
|
"""Poll for HITL approval status.
|
|
@@ -335,19 +462,15 @@ class GovernanceClient:
|
|
|
335
462
|
return None
|
|
336
463
|
|
|
337
464
|
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
|
|
465
|
+
# SDK-side expiration check — run on the raw dict BEFORE parsing
|
|
466
|
+
# (matches openbox_core.client.check_expiration's own call order:
|
|
467
|
+
# check_expiration(data) then ApprovalResult.from_dict(data)).
|
|
468
|
+
# Handles ISO 'Z', ISO offset, and space-separated DB timestamp
|
|
469
|
+
# formats; a malformed timestamp is logged and left un-flagged
|
|
470
|
+
# rather than raised, so one bad timestamp string degrades to
|
|
471
|
+
# "expiration not confirmed" instead of aborting the whole poll.
|
|
472
|
+
check_expiration(data)
|
|
473
|
+
return parse_approval_response(data)
|
|
351
474
|
|
|
352
475
|
except Exception:
|
|
353
476
|
return None
|
openbox_langgraph/config.py
CHANGED
|
@@ -80,9 +80,37 @@ class GovernanceConfig:
|
|
|
80
80
|
skip_tool_types: set[str] = field(default_factory=set)
|
|
81
81
|
hitl: HITLConfig = field(default_factory=HITLConfig)
|
|
82
82
|
session_id: str | None = None
|
|
83
|
+
multi_agent_session_id: str | None = None
|
|
84
|
+
"""Optional multi-agent session correlation id, distinct from `session_id`.
|
|
85
|
+
|
|
86
|
+
Threaded onto the base SDK's `ActivityContext.multi_agent_session_id` at
|
|
87
|
+
activity-boundary dual-write registration — never mixed into `session_id`,
|
|
88
|
+
which stays the single-agent chat-session identifier the legacy governance
|
|
89
|
+
events already carry."""
|
|
83
90
|
agent_name: str | None = None
|
|
84
91
|
task_queue: str = "langgraph"
|
|
85
92
|
use_native_interrupt: bool = False
|
|
93
|
+
use_core_instrumentation: bool = True
|
|
94
|
+
"""Route hook governance through the shared ``openbox_core`` base
|
|
95
|
+
instrumentation — the only hook runtime. Default ``True``. Setting it
|
|
96
|
+
``False`` fails fast (``OpenBoxConfigError``): legacy in-repo hook
|
|
97
|
+
governance has been removed, so there is nothing to fall back to."""
|
|
98
|
+
strict_activity_context: bool = False
|
|
99
|
+
"""Fail loudly when a tool cannot be bound to a proven ActivityContext.
|
|
100
|
+
|
|
101
|
+
A tool run is bindable only inside a governed turn — the handler supplies
|
|
102
|
+
the turn's ``workflow_id``/``run_id`` down each tool's ``RunnableConfig``.
|
|
103
|
+
When that turn identity is absent (e.g. a tool driven outside the handler),
|
|
104
|
+
the ToolNode binder cannot prove which activity a hook span belongs to.
|
|
105
|
+
|
|
106
|
+
Default ``False``: log a warning once per such invocation and execute the
|
|
107
|
+
tool UNBOUND (its hook spans stay intentionally unattached — never guessed).
|
|
108
|
+
``True`` (a test/debug aid): raise ``OpenBoxConfigError`` BEFORE the tool
|
|
109
|
+
body runs, so the tool never executes unbound. Note the raise is thrown from
|
|
110
|
+
inside the ``ToolNode`` call, so a ``ToolNode`` with the default
|
|
111
|
+
``handle_tool_errors=True`` converts it into an error ``ToolMessage``
|
|
112
|
+
(loud and visible, tool still not run) rather than propagating; build the
|
|
113
|
+
``ToolNode`` with ``handle_tool_errors=False`` to make it hard-fail the run."""
|
|
86
114
|
root_node_names: set[str] = field(default_factory=set)
|
|
87
115
|
tool_type_map: dict[str, str] = field(default_factory=dict)
|
|
88
116
|
"""Optional mapping of tool name → tool_type for execution tree classification.
|
|
@@ -142,9 +170,12 @@ def merge_config(partial: dict[str, Any] | None = None) -> GovernanceConfig:
|
|
|
142
170
|
skip_tool_types=_to_set(partial.get("skip_tool_types")),
|
|
143
171
|
hitl=hitl,
|
|
144
172
|
session_id=partial.get("session_id"),
|
|
173
|
+
multi_agent_session_id=partial.get("multi_agent_session_id"),
|
|
145
174
|
agent_name=partial.get("agent_name"),
|
|
146
175
|
task_queue=partial.get("task_queue", "langgraph"),
|
|
147
176
|
use_native_interrupt=partial.get("use_native_interrupt", False),
|
|
177
|
+
use_core_instrumentation=partial.get("use_core_instrumentation", True),
|
|
178
|
+
strict_activity_context=partial.get("strict_activity_context", False),
|
|
148
179
|
root_node_names=_to_set(partial.get("root_node_names")),
|
|
149
180
|
tool_type_map=tool_type_map,
|
|
150
181
|
)
|