openbox-langgraph-sdk-python 1.1.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 +1 -1
- openbox_langgraph/activity_approval.py +156 -0
- openbox_langgraph/client.py +1 -1
- openbox_langgraph/core_adapter.py +27 -75
- openbox_langgraph/core_runtime.py +1 -1
- openbox_langgraph/langgraph_handler.py +20 -147
- {openbox_langgraph_sdk_python-1.1.0.dist-info → openbox_langgraph_sdk_python-1.2.0.dist-info}/METADATA +12 -2
- {openbox_langgraph_sdk_python-1.1.0.dist-info → openbox_langgraph_sdk_python-1.2.0.dist-info}/RECORD +10 -9
- {openbox_langgraph_sdk_python-1.1.0.dist-info → openbox_langgraph_sdk_python-1.2.0.dist-info}/WHEEL +0 -0
- {openbox_langgraph_sdk_python-1.1.0.dist-info → openbox_langgraph_sdk_python-1.2.0.dist-info}/licenses/LICENSE +0 -0
openbox_langgraph/__init__.py
CHANGED
|
@@ -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
|
@@ -41,7 +41,7 @@ if TYPE_CHECKING:
|
|
|
41
41
|
from openbox_core.client import EvaluationClient
|
|
42
42
|
from openbox_core.gate import GovernanceGate
|
|
43
43
|
|
|
44
|
-
_SDK_PACKAGE_VERSION = "1.
|
|
44
|
+
_SDK_PACKAGE_VERSION = "1.2.0"
|
|
45
45
|
_SDK_IDENTIFIER = f"openbox-langgraph-python-v{_SDK_PACKAGE_VERSION}"
|
|
46
46
|
|
|
47
47
|
|
|
@@ -1,24 +1,8 @@
|
|
|
1
1
|
# openbox_langgraph/core_adapter.py
|
|
2
|
-
"""
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
the handler's existing ``ainvoke``/``astream_governed`` catch blocks already
|
|
7
|
-
understand — no new error vocabulary, no new control flow at the call site.
|
|
8
|
-
|
|
9
|
-
RAISE-ONLY approval, mirroring the legacy hook path exactly: an inline
|
|
10
|
-
blocking poller on the event-loop thread (``time.sleep`` in
|
|
11
|
-
``ApprovalPoller.wait_for_decision``) would freeze every other coroutine on
|
|
12
|
-
that loop — LangGraph tools/LLM calls run as concurrent ``asyncio.Task``s, so
|
|
13
|
-
blocking the loop thread blocks ALL of them, not just the approval-pending
|
|
14
|
-
one. The handler's OUTER catch/poll/retry in ``ainvoke``/``_pre_screen_input``
|
|
15
|
-
is the single HITL driver today (catches ``GovernanceBlockedError`` with
|
|
16
|
-
``verdict == "require_approval"``, awaits ``poll_until_decision``, retries);
|
|
17
|
-
this adapter reproduces that same shape for hook-level (started-stage
|
|
18
|
-
HTTP/DB/file/function) verdicts so the SAME outer loop drives them too.
|
|
19
|
-
Defining ``handle_approval_sync`` here also pre-empts
|
|
20
|
-
``openbox_core.hooks.preflight.HookRuntime._sync_approval``'s fallback to its
|
|
21
|
-
own inline ``ApprovalPoller`` (adapter-native flow always wins when present).
|
|
2
|
+
"""Map base-SDK verdicts to native errors and wait at pending operations.
|
|
3
|
+
|
|
4
|
+
A handler configures an activity waiter for each governed turn. Standalone
|
|
5
|
+
adapters without a waiter still raise REQUIRE_APPROVAL to fail closed.
|
|
22
6
|
"""
|
|
23
7
|
|
|
24
8
|
from __future__ import annotations
|
|
@@ -29,6 +13,7 @@ from openbox_core.context import ContextStore
|
|
|
29
13
|
from openbox_core.contracts.context import ActivityContext
|
|
30
14
|
from openbox_core.contracts.results import EvaluationResult, Verdict
|
|
31
15
|
|
|
16
|
+
from openbox_langgraph.activity_approval import ActivityApprovalWaiter
|
|
32
17
|
from openbox_langgraph.errors import GovernanceBlockedError, GovernanceHaltError
|
|
33
18
|
|
|
34
19
|
__all__ = ["LangGraphFrameworkAdapter"]
|
|
@@ -53,6 +38,7 @@ class LangGraphFrameworkAdapter:
|
|
|
53
38
|
context_store: ContextStore | None = None,
|
|
54
39
|
) -> None:
|
|
55
40
|
self._store = context_store if context_store is not None else ContextStore()
|
|
41
|
+
self.approval_waiter: ActivityApprovalWaiter | None = None
|
|
56
42
|
|
|
57
43
|
# ── Lifecycle verdicts (WorkflowStarted/LLMStarted pre-screen, etc.) ───
|
|
58
44
|
|
|
@@ -90,43 +76,33 @@ class LangGraphFrameworkAdapter:
|
|
|
90
76
|
reason = result.reason or "Blocked by governance"
|
|
91
77
|
raise GovernanceBlockedError(result.verdict.value, reason, identifier)
|
|
92
78
|
|
|
93
|
-
# ── Approval
|
|
79
|
+
# ── Approval: resume the same operation after its decision ────────────
|
|
94
80
|
|
|
95
81
|
async def handle_approval(
|
|
96
82
|
self, result: EvaluationResult, context: ActivityContext | None = None
|
|
97
83
|
) -> None:
|
|
98
|
-
"""
|
|
99
|
-
|
|
100
|
-
The base ``HookRuntime._adecide_started`` treats a normal RETURN as
|
|
101
|
-
"approved, proceed" — raising here is the correct "not approved yet"
|
|
102
|
-
signal for a ``requires_approval()`` verdict, so the handler's outer
|
|
103
|
-
catch/poll/retry loop drives the approval flow.
|
|
104
|
-
"""
|
|
84
|
+
"""Suspend this coroutine without unwinding the graph or blocking the loop."""
|
|
105
85
|
ctx = context if context is not None else self._store.current_activity_context()
|
|
106
|
-
self.
|
|
86
|
+
if self.approval_waiter is None or ctx is None:
|
|
87
|
+
self._raise_pending_approval(result, ctx)
|
|
88
|
+
try:
|
|
89
|
+
await self.approval_waiter.wait(result, ctx)
|
|
90
|
+
except Exception:
|
|
91
|
+
self._store.mark_activity_aborted(ctx.workflow_id, ctx.activity_id)
|
|
92
|
+
raise
|
|
107
93
|
|
|
108
94
|
def handle_approval_sync(
|
|
109
95
|
self, result: EvaluationResult, *, context: ActivityContext | None = None
|
|
110
96
|
) -> None:
|
|
111
|
-
"""
|
|
112
|
-
|
|
113
|
-
``context`` is the span-resolved ``ActivityContext``
|
|
114
|
-
``HookRuntime._sync_approval`` passes explicitly — it can differ from
|
|
115
|
-
the ambient ``ContextStore.current_activity_context()`` (e.g. a sync
|
|
116
|
-
tool running inside ``run_in_executor``, where the ContextVar bound on
|
|
117
|
-
the async stream-consumer never reached the worker thread). Preferring
|
|
118
|
-
the passed context over the ambient lookup keeps the abort-mark keyed
|
|
119
|
-
on the SAME workflow/activity id the operation is actually running
|
|
120
|
-
under.
|
|
121
|
-
|
|
122
|
-
Defining this method is what stops
|
|
123
|
-
``HookRuntime._sync_approval`` from falling back to its own inline
|
|
124
|
-
``ApprovalPoller.wait_for_decision`` (a blocking ``time.sleep`` loop on
|
|
125
|
-
whatever thread called this) — an adapter-native
|
|
126
|
-
``handle_approval_sync`` always takes priority when present.
|
|
127
|
-
"""
|
|
97
|
+
"""Suspend the sync tool's worker thread, preserving its current stack."""
|
|
128
98
|
ctx = context if context is not None else self._store.current_activity_context()
|
|
129
|
-
self.
|
|
99
|
+
if self.approval_waiter is None or ctx is None:
|
|
100
|
+
self._raise_pending_approval(result, ctx)
|
|
101
|
+
try:
|
|
102
|
+
self.approval_waiter.wait_sync(result, ctx)
|
|
103
|
+
except Exception:
|
|
104
|
+
self._store.mark_activity_aborted(ctx.workflow_id, ctx.activity_id)
|
|
105
|
+
raise
|
|
130
106
|
|
|
131
107
|
def _raise_pending_approval(
|
|
132
108
|
self, result: EvaluationResult, ctx: ActivityContext | None
|
|
@@ -151,36 +127,12 @@ class LangGraphFrameworkAdapter:
|
|
|
151
127
|
adapter's ``on_completed_hook_result``, which is also a no-op)."""
|
|
152
128
|
return None
|
|
153
129
|
|
|
154
|
-
# ──
|
|
130
|
+
# ── Compatibility helper for callers managing abort marks themselves ──
|
|
155
131
|
|
|
156
132
|
def reset_after_approval(self, workflow_id: str | None) -> None:
|
|
157
|
-
"""Clear
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
a stale abort flag from the blocked first pass.
|
|
161
|
-
|
|
162
|
-
Scoped to ``workflow_id`` (the per-TURN id), not a single
|
|
163
|
-
``activity_id``: an approved retry re-invokes the underlying graph
|
|
164
|
-
directly (bypassing this SDK's own registration), so the exact
|
|
165
|
-
``activity_id`` its operations will use is not knowable up front — but
|
|
166
|
-
every activity a hook could have aborted THIS turn shares the SAME
|
|
167
|
-
``workflow_id``. See ``TraceContextRegistry.clear_aborted_for_workflow``
|
|
168
|
-
for why the store needed a new, narrower-than-``sweep`` method for this
|
|
169
|
-
(``sweep`` also drops trace state the retry still needs).
|
|
170
|
-
|
|
171
|
-
Call this AFTER ``poll_until_decision`` resolves and BEFORE
|
|
172
|
-
re-invoking the graph — never before the poll (that would let a
|
|
173
|
-
second concurrent hook evaluation ignore the still-pending approval).
|
|
174
|
-
|
|
175
|
-
Clears the base store — via the ``TraceContextRegistry`` published on
|
|
176
|
-
the store as ``store.registry`` (``create_core_runtime`` sets it to the
|
|
177
|
-
runtime's registry; duck-typed by attribute, not import, to avoid a
|
|
178
|
-
circular import), which sweeps every activity key registered this turn.
|
|
179
|
-
Falls back to a direct ``clear_activity_aborted`` on the ambient bound
|
|
180
|
-
context when no registry is published (a store built outside
|
|
181
|
-
``create_core_runtime``).
|
|
182
|
-
|
|
183
|
-
No-op when ``workflow_id`` is falsy (nothing is ever keyed on it).
|
|
133
|
+
"""Clear a workflow's abort marks for legacy callers.
|
|
134
|
+
|
|
135
|
+
Normal governed turns wait in place and do not use this helper.
|
|
184
136
|
"""
|
|
185
137
|
if not workflow_id:
|
|
186
138
|
return
|
|
@@ -30,6 +30,7 @@ from openbox_langchain.activity_bridge import EventType
|
|
|
30
30
|
from opentelemetry import context as otel_context
|
|
31
31
|
from opentelemetry import trace as otel_trace
|
|
32
32
|
|
|
33
|
+
from openbox_langgraph.activity_approval import ActivityApprovalWaiter
|
|
33
34
|
from openbox_langgraph.activity_context_binding import (
|
|
34
35
|
build_activity_context,
|
|
35
36
|
register_activity,
|
|
@@ -38,6 +39,7 @@ from openbox_langgraph.activity_context_binding import (
|
|
|
38
39
|
)
|
|
39
40
|
from openbox_langgraph.client import GovernanceClient
|
|
40
41
|
from openbox_langgraph.config import get_global_config, merge_config
|
|
42
|
+
from openbox_langgraph.core_adapter import LangGraphFrameworkAdapter
|
|
41
43
|
from openbox_langgraph.core_runtime import create_core_runtime, get_trace_registry
|
|
42
44
|
from openbox_langgraph.errors import (
|
|
43
45
|
ApprovalExpiredError,
|
|
@@ -67,53 +69,6 @@ _logger = logging.getLogger(__name__)
|
|
|
67
69
|
_otel_tracer = otel_trace.get_tracer("openbox-langgraph")
|
|
68
70
|
|
|
69
71
|
|
|
70
|
-
def _extract_governance_blocked(exc: Exception) -> GovernanceBlockedError | None:
|
|
71
|
-
"""Walk exception chain to find a wrapped GovernanceBlockedError.
|
|
72
|
-
|
|
73
|
-
LLM SDKs (OpenAI, Anthropic) wrap httpx errors. When an OTel hook raises
|
|
74
|
-
GovernanceBlockedError inside httpx, the LLM SDK wraps it as APIConnectionError.
|
|
75
|
-
This function unwraps the chain via __cause__ / __context__ to recover it.
|
|
76
|
-
"""
|
|
77
|
-
cause: BaseException | None = exc
|
|
78
|
-
seen: set[int] = set()
|
|
79
|
-
while cause is not None:
|
|
80
|
-
if id(cause) in seen:
|
|
81
|
-
break
|
|
82
|
-
seen.add(id(cause))
|
|
83
|
-
if isinstance(cause, GovernanceBlockedError):
|
|
84
|
-
return cause
|
|
85
|
-
cause = getattr(cause, '__cause__', None) or getattr(cause, '__context__', None)
|
|
86
|
-
return None
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
def _approval_poll_activity_id(hook_err: GovernanceBlockedError, run_id: str) -> str:
|
|
90
|
-
"""Resolve the activity id `ainvoke`'s outer HITL poll should use (C5).
|
|
91
|
-
|
|
92
|
-
Core matches a pending approval on `(workflow_id, run_id, activity_id)`
|
|
93
|
-
exactly (`GovernanceClient.poll_approval` / `ApprovalPollParams` — no
|
|
94
|
-
other identifying field travels in that request), so polling the WRONG
|
|
95
|
-
activity_id never resolves and `poll_until_decision`'s unbounded `while
|
|
96
|
-
True` loop hangs forever.
|
|
97
|
-
|
|
98
|
-
A REQUIRE_APPROVAL raised by the pure-LangChain-Core tool callback
|
|
99
|
-
(installed under the C1 condition) carries the tool's REAL activity_id in
|
|
100
|
-
`.identifier` — set by `LangGraphFrameworkAdapter._raise_pending_approval`
|
|
101
|
-
from `current_activity_context()`, which resolves correctly here because
|
|
102
|
-
`run_inline=True` means the callback raises INSIDE the ToolNode's
|
|
103
|
-
`activity_scope(ctx, store=store)` (see `tool_activity_binding.py`). Use
|
|
104
|
-
it verbatim so the poll targets the SAME row the tool's ActivityStarted
|
|
105
|
-
opened — mirroring the pre-existing tool_start/tool_end HITL poll in
|
|
106
|
-
`_process_event`, which has always polled the tool's own activity_id
|
|
107
|
-
rather than a synthetic hook id.
|
|
108
|
-
|
|
109
|
-
Falls back to the legacy synthetic `f"{run_id}-hook"` id when no
|
|
110
|
-
identifier is carried (the base-hook — HTTP/DB/file/function preflight —
|
|
111
|
-
REQUIRE_APPROVAL path this `except` block already handled before this
|
|
112
|
-
phase; those raises carry no tool activity_id and are unaffected).
|
|
113
|
-
"""
|
|
114
|
-
return hook_err.identifier or f"{run_id}-hook"
|
|
115
|
-
|
|
116
|
-
|
|
117
72
|
# ═══════════════════════════════════════════════════════════════════
|
|
118
73
|
# Run buffer (tracks in-flight runs for duration/context)
|
|
119
74
|
# ═══════════════════════════════════════════════════════════════════
|
|
@@ -360,6 +315,7 @@ class OpenBoxLangGraphHandler:
|
|
|
360
315
|
# below. `None` here means "no callback, no bridge, consumer governs
|
|
361
316
|
# every tool event unconditionally", matching today's behavior exactly.
|
|
362
317
|
self._activity_bridge: ActivityBridge | None = None
|
|
318
|
+
self._activity_approvals: ActivityApprovalWaiter | None = None
|
|
363
319
|
|
|
364
320
|
if opts.client:
|
|
365
321
|
# Injected client (e.g. a test double, or a subclass overriding
|
|
@@ -607,9 +563,8 @@ class OpenBoxLangGraphHandler:
|
|
|
607
563
|
No-op when the handler has no core runtime (`_core_runtime is None` —
|
|
608
564
|
injected-client handlers, legacy-only). Every public entry point calls
|
|
609
565
|
this from the OUTERMOST `finally` of its stream loop so it runs
|
|
610
|
-
exactly once per turn regardless of success, mid-stream exception,
|
|
611
|
-
|
|
612
|
-
placement in each entry point for why ordering after the retry matters.
|
|
566
|
+
exactly once per turn regardless of success, mid-stream exception,
|
|
567
|
+
approval rejection, or cancellation while waiting for approval.
|
|
613
568
|
Stays SYNCHRONOUS (existing tests spy/patch it with a plain callable
|
|
614
569
|
called without `await`) — the C6 orphan-close below uses the base
|
|
615
570
|
SDK's SYNC gate for the same reason.
|
|
@@ -635,18 +590,12 @@ class OpenBoxLangGraphHandler:
|
|
|
635
590
|
above never sees that key) — clear those directly on the runtime's
|
|
636
591
|
store so they cannot leak for the handler's lifetime.
|
|
637
592
|
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
retries it; `astream_governed`/`astream`/`astream_events` have no
|
|
642
|
-
catch/poll loop at all — pre-existing, HITL retry is `ainvoke`-only —
|
|
643
|
-
and simply propagate it to the caller), so a per-entry-point catch
|
|
644
|
-
site is not a reliable place to set the flag. `clear_activity_aborted`
|
|
645
|
-
is an idempotent set-discard — a no-op for a record that was never
|
|
646
|
-
actually aborted — so clearing unconditionally is always safe.
|
|
647
|
-
`record.abort_marked` is still set (see `_mark_bridge_abort`) and
|
|
648
|
-
checked here as a fast-path/diagnostic signal, not a gate.
|
|
593
|
+
Clear abort marks for every swept tool record: terminal governance
|
|
594
|
+
failures can escape through any entry point, including wrapped errors.
|
|
595
|
+
Clearing is idempotent for activities that were never aborted.
|
|
649
596
|
"""
|
|
597
|
+
if self._activity_approvals is not None:
|
|
598
|
+
self._activity_approvals.end_turn(workflow_id)
|
|
650
599
|
if self._core_runtime is None:
|
|
651
600
|
return
|
|
652
601
|
get_trace_registry(self._core_runtime).sweep(workflow_id)
|
|
@@ -663,27 +612,6 @@ class OpenBoxLangGraphHandler:
|
|
|
663
612
|
)
|
|
664
613
|
self._close_orphan_bridge_tool(workflow_id, record)
|
|
665
614
|
|
|
666
|
-
def _mark_bridge_abort(self, workflow_id: str, activity_id: str) -> None:
|
|
667
|
-
"""Record (M19) that the base store's abort mark for `activity_id` was
|
|
668
|
-
set via the ToolNode-seam ContextVar path, NOT `register_activity` —
|
|
669
|
-
so `_cleanup_turn`'s `TraceContextRegistry.sweep` (which only clears
|
|
670
|
-
keys it registered) will never see it, and the mark would otherwise
|
|
671
|
-
leak on the runtime's `ContextStore` for the handler's lifetime.
|
|
672
|
-
|
|
673
|
-
No-op when this turn has no bridge (consumer-governed path — the
|
|
674
|
-
adapter's OWN abort-mark clearing there is exactly what `sweep`
|
|
675
|
-
already covers, via `register_activity`'s trace-only dual-write).
|
|
676
|
-
Safe to call with an activity_id the bridge never prepared (e.g. the
|
|
677
|
-
legacy synthetic `f"{run_id}-hook"` id from a base-hook approval,
|
|
678
|
-
C5's fallback branch) — `ActivityBridge.get` returns None and this is
|
|
679
|
-
a no-op, exactly matching pre-phase-4 behavior for that path.
|
|
680
|
-
"""
|
|
681
|
-
if self._activity_bridge is None:
|
|
682
|
-
return
|
|
683
|
-
record = self._activity_bridge.get(workflow_id, activity_id)
|
|
684
|
-
if record is not None:
|
|
685
|
-
record.abort_marked = True
|
|
686
|
-
|
|
687
615
|
def _close_orphan_bridge_tool(self, workflow_id: str, record: Any) -> None:
|
|
688
616
|
"""Best-effort failed ActivityCompleted for a swept orphan tool row.
|
|
689
617
|
|
|
@@ -714,27 +642,6 @@ class OpenBoxLangGraphHandler:
|
|
|
714
642
|
exc_info=True,
|
|
715
643
|
)
|
|
716
644
|
|
|
717
|
-
def _reset_after_approval(self, workflow_id: str) -> None:
|
|
718
|
-
"""Clear the abort mark(s) a hook set for this turn BEFORE an approved
|
|
719
|
-
REQUIRE_APPROVAL retry re-invokes the graph, so the retry runs
|
|
720
|
-
GOVERNED instead of short-circuiting on the stale abort flag the
|
|
721
|
-
blocked first pass left behind.
|
|
722
|
-
|
|
723
|
-
No-op when the handler has no core runtime (`_core_runtime is None`)
|
|
724
|
-
OR the runtime's adapter is the base default `CoreAdapter` (only
|
|
725
|
-
reachable when `use_core_instrumentation=False` — that adapter has no
|
|
726
|
-
`reset_after_approval`, matching this turn never having armed base
|
|
727
|
-
instrumentation in the first place, so there is nothing to reset).
|
|
728
|
-
Call BEFORE re-invoking the graph, AFTER `poll_until_decision`
|
|
729
|
-
resolves — matches `LangGraphFrameworkAdapter.reset_after_approval`'s
|
|
730
|
-
own ordering contract.
|
|
731
|
-
"""
|
|
732
|
-
if self._core_runtime is None:
|
|
733
|
-
return
|
|
734
|
-
reset = getattr(self._core_runtime.adapter, "reset_after_approval", None)
|
|
735
|
-
if reset is not None:
|
|
736
|
-
reset(workflow_id)
|
|
737
|
-
|
|
738
645
|
def _governed_config(
|
|
739
646
|
self,
|
|
740
647
|
config: dict[str, Any] | None,
|
|
@@ -782,6 +689,15 @@ class OpenBoxLangGraphHandler:
|
|
|
782
689
|
untouched by this phase — see ``_process_event``'s LLMCompleted
|
|
783
690
|
fallback branch.
|
|
784
691
|
"""
|
|
692
|
+
if self._core_runtime is not None and isinstance(
|
|
693
|
+
self._core_runtime.adapter, LangGraphFrameworkAdapter
|
|
694
|
+
):
|
|
695
|
+
if self._activity_approvals is None:
|
|
696
|
+
self._activity_approvals = ActivityApprovalWaiter(
|
|
697
|
+
self._core_runtime.client, self._config.hitl
|
|
698
|
+
)
|
|
699
|
+
self._core_runtime.adapter.approval_waiter = self._activity_approvals
|
|
700
|
+
self._activity_approvals.begin_turn(workflow_id, run_id)
|
|
785
701
|
callbacks: list[Any] = []
|
|
786
702
|
if self._activity_bridge is not None and self._core_runtime is not None:
|
|
787
703
|
registry = get_trace_registry(self._core_runtime)
|
|
@@ -906,53 +822,10 @@ class OpenBoxLangGraphHandler:
|
|
|
906
822
|
output = stream_event.data.get("output")
|
|
907
823
|
if isinstance(output, dict):
|
|
908
824
|
final_output = output
|
|
909
|
-
except GovernanceBlockedError as hook_err:
|
|
910
|
-
if hook_err.verdict != "require_approval":
|
|
911
|
-
raise
|
|
912
|
-
_logger.info("[OpenBox] Hook REQUIRE_APPROVAL during ainvoke, polling")
|
|
913
|
-
poll_activity_id = _approval_poll_activity_id(hook_err, run_id)
|
|
914
|
-
self._mark_bridge_abort(workflow_id, poll_activity_id)
|
|
915
|
-
await poll_until_decision(
|
|
916
|
-
self._client,
|
|
917
|
-
HITLPollParams(
|
|
918
|
-
workflow_id=workflow_id,
|
|
919
|
-
run_id=run_id,
|
|
920
|
-
activity_id=poll_activity_id,
|
|
921
|
-
activity_type="hook",
|
|
922
|
-
),
|
|
923
|
-
self._config.hitl,
|
|
924
|
-
)
|
|
925
|
-
_logger.info("[OpenBox] Approval granted, retrying ainvoke")
|
|
926
|
-
self._reset_after_approval(workflow_id)
|
|
927
|
-
final_output = await self._graph.ainvoke(input, config=cfg, **kwargs)
|
|
928
825
|
except _CoreOpenBoxConfigError as exc:
|
|
929
826
|
_raise_core_error(exc)
|
|
930
|
-
except Exception as exc:
|
|
931
|
-
hook_err = _extract_governance_blocked(exc)
|
|
932
|
-
if hook_err is None or hook_err.verdict != "require_approval":
|
|
933
|
-
raise
|
|
934
|
-
_logger.info("[OpenBox] Hook REQUIRE_APPROVAL (wrapped) during ainvoke, polling")
|
|
935
|
-
poll_activity_id = _approval_poll_activity_id(hook_err, run_id)
|
|
936
|
-
self._mark_bridge_abort(workflow_id, poll_activity_id)
|
|
937
|
-
await poll_until_decision(
|
|
938
|
-
self._client,
|
|
939
|
-
HITLPollParams(
|
|
940
|
-
workflow_id=workflow_id,
|
|
941
|
-
run_id=run_id,
|
|
942
|
-
activity_id=poll_activity_id,
|
|
943
|
-
activity_type="hook",
|
|
944
|
-
),
|
|
945
|
-
self._config.hitl,
|
|
946
|
-
)
|
|
947
|
-
_logger.info("[OpenBox] Approval granted, retrying ainvoke")
|
|
948
|
-
self._reset_after_approval(workflow_id)
|
|
949
|
-
final_output = await self._graph.ainvoke(input, config=cfg, **kwargs)
|
|
950
827
|
finally:
|
|
951
|
-
#
|
|
952
|
-
# graph INSIDE the `except` blocks above, so this only fires once
|
|
953
|
-
# the (possibly retried) turn is fully done — the retry keeps its
|
|
954
|
-
# dual-write context intact instead of racing a cleanup that
|
|
955
|
-
# unregisters it mid-retry. See `_cleanup_turn`.
|
|
828
|
+
# Pending activities wait in place; the graph is never replayed.
|
|
956
829
|
self._cleanup_turn(workflow_id)
|
|
957
830
|
|
|
958
831
|
return final_output
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: openbox-langgraph-sdk-python
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.2.0
|
|
4
4
|
Summary: OpenBox governance and observability SDK for LangGraph
|
|
5
5
|
License: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -373,7 +373,17 @@ governed = create_openbox_graph_handler(
|
|
|
373
373
|
)
|
|
374
374
|
```
|
|
375
375
|
|
|
376
|
-
The human approves or rejects from the OpenBox dashboard.
|
|
376
|
+
The human approves or rejects from the OpenBox dashboard. Approval resumes the
|
|
377
|
+
pending tool or hook operation with the same activity ID and graph state. Completed
|
|
378
|
+
nodes and earlier tool side effects are not replayed. Later approval requests wait
|
|
379
|
+
independently, including through `astream_governed` and `astream_events`. Rejection
|
|
380
|
+
raises `ApprovalRejectedError`; expiration raises `ApprovalExpiredError`.
|
|
381
|
+
|
|
382
|
+
Async operations await the decision without blocking the event loop. Synchronous
|
|
383
|
+
tools wait in their executor thread. A synchronous operation called directly on the
|
|
384
|
+
event-loop thread cannot safely suspend its stack, so pending approval raises
|
|
385
|
+
`OpenBoxConfigError`; use an async operation or `asyncio.to_thread()` in that case.
|
|
386
|
+
Cancelling the governed turn also stops pending approval waits.
|
|
377
387
|
|
|
378
388
|
| Key | Type | Default | Description |
|
|
379
389
|
|---|---|---|---|
|
{openbox_langgraph_sdk_python-1.1.0.dist-info → openbox_langgraph_sdk_python-1.2.0.dist-info}/RECORD
RENAMED
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
openbox_langgraph/__init__.py,sha256=
|
|
1
|
+
openbox_langgraph/__init__.py,sha256=dfDnwf1Nu6jQ7uFI_hSt5sOfNzHNhRXzQ-a-FhhJopc,4142
|
|
2
|
+
openbox_langgraph/activity_approval.py,sha256=UTBq_XhP2hSrDO8UtQv_zUqyycx8fLRx2jvbc8qlIk4,5922
|
|
2
3
|
openbox_langgraph/activity_context_binding.py,sha256=kI_Qu7R0mjM2js50sN3H-9CNWjzF8tI7Xg9afvnQeHo,4789
|
|
3
|
-
openbox_langgraph/client.py,sha256=
|
|
4
|
+
openbox_langgraph/client.py,sha256=EaExu3PTWiB8NAkSTSoYpU-d-6tIqGg2pip2nLTpNLc,26835
|
|
4
5
|
openbox_langgraph/config.py,sha256=XSbd5RMEsf1VWFQkCx7hJceTu_EcSs_uTMYL_LIybRE,17183
|
|
5
|
-
openbox_langgraph/core_adapter.py,sha256=
|
|
6
|
+
openbox_langgraph/core_adapter.py,sha256=8Xrc4TBoGgVxgzqrTHdkUYJNWWrd_NfG4bzNivZLTMs,8242
|
|
6
7
|
openbox_langgraph/core_events.py,sha256=UXS9iWFPBLcClbfMa9MjaJDp4ZjMrm49x_oqX3TAZQg,6237
|
|
7
|
-
openbox_langgraph/core_runtime.py,sha256=
|
|
8
|
+
openbox_langgraph/core_runtime.py,sha256=nKa1AQGL65SL_ct2FN2CqSG1U33vjjJW8gXPVWd-PoI,8600
|
|
8
9
|
openbox_langgraph/errors.py,sha256=Xry_Ir6U2wmKerluIJ18eHPzuE5YCq0Ib1uY58raXro,5137
|
|
9
10
|
openbox_langgraph/hitl.py,sha256=p8Xyxp9vvIBciumghM2RGJ53deIIwbufLRmw-PPLteU,2751
|
|
10
11
|
openbox_langgraph/identity.py,sha256=t4jGkUNGz4OwcGXrWMgy5jVriEYJwCE_509Yi-MKaHY,5965
|
|
11
|
-
openbox_langgraph/langgraph_handler.py,sha256=
|
|
12
|
+
openbox_langgraph/langgraph_handler.py,sha256=TxaPuInh5iUACzoY63AM4uItZSiyJRwBrSfyIglj_Qo,99360
|
|
12
13
|
openbox_langgraph/langgraph_hook_runtime.py,sha256=FVoTvay5ZJAI8iNxRCdPNEeldi7l50AMRGP_vmWU6G4,8757
|
|
13
14
|
openbox_langgraph/otel_setup.py,sha256=Qk8GiXZ1f4WlSjT1_Id48dRfoZGkP_A0VCQFVX2wysU,1584
|
|
14
15
|
openbox_langgraph/span_processor.py,sha256=MfsHG4La0P-G1mo2GCgFqU-al5iljExTAA1bSrCRtJA,14045
|
|
@@ -17,7 +18,7 @@ openbox_langgraph/trace_context_registry.py,sha256=fcU-WXt-8VLsyd8irJ1feQQCeLiGF
|
|
|
17
18
|
openbox_langgraph/tracing.py,sha256=24CIs5hgnK2nRH0vF7g96L3fuxyP66w24Oe0sJbBCc8,7299
|
|
18
19
|
openbox_langgraph/types.py,sha256=zUrk44-1PBWirW7bPPrsLcMNzJSVYCdpzPuu4iBwIXs,26368
|
|
19
20
|
openbox_langgraph/verdict_handler.py,sha256=YmZjBxzOa2RBbkF13xDCPUAGzz7m1hwdMErwOVDl2WM,8323
|
|
20
|
-
openbox_langgraph_sdk_python-1.
|
|
21
|
-
openbox_langgraph_sdk_python-1.
|
|
22
|
-
openbox_langgraph_sdk_python-1.
|
|
23
|
-
openbox_langgraph_sdk_python-1.
|
|
21
|
+
openbox_langgraph_sdk_python-1.2.0.dist-info/METADATA,sha256=C031jgvIgZHSzaNLNLwX5wNGbbGC54TqEfUVUuGHAA0,24815
|
|
22
|
+
openbox_langgraph_sdk_python-1.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
23
|
+
openbox_langgraph_sdk_python-1.2.0.dist-info/licenses/LICENSE,sha256=bPyy7QFDMnA5r6asaR3iD9rQvpjz5cUFk1ouHCuBYIY,1071
|
|
24
|
+
openbox_langgraph_sdk_python-1.2.0.dist-info/RECORD,,
|
{openbox_langgraph_sdk_python-1.1.0.dist-info → openbox_langgraph_sdk_python-1.2.0.dist-info}/WHEEL
RENAMED
|
File without changes
|
|
File without changes
|