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.
@@ -17,7 +17,7 @@ Example:
17
17
  ... )
18
18
  """
19
19
 
20
- __version__ = "1.1.0"
20
+ __version__ = "1.2.0"
21
21
 
22
22
  from openbox_langgraph.client import GovernanceClient, build_auth_headers
23
23
  from openbox_langgraph.config import (
@@ -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)
@@ -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.1.0"
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
- """LangGraph ``FrameworkAdapter`` for the opt-in ``openbox_core`` hook runtime.
3
-
4
- Maps base-SDK governance verdicts onto the LangGraph-native error types
5
- (``openbox_langgraph.errors.GovernanceBlockedError``/``GovernanceHaltError``)
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 (RAISE-ONLY — never an inline blocking wait) ──────────────
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
- """Async started-hook REQUIRE_APPROVAL -> raise, never await inline.
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._raise_pending_approval(result, ctx)
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
- """Sync started-hook REQUIRE_APPROVAL -> raise, never poll inline.
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._raise_pending_approval(result, ctx)
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
- # ── Post-approval reset (clears the base store before the retry) ───────
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 every abort mark registered under ``workflow_id`` on the base
158
- store, so the caller's retry (already GRANTED by
159
- ``poll_until_decision``) runs GOVERNED instead of short-circuiting on
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
@@ -54,7 +54,7 @@ from openbox_langgraph.trace_context_registry import (
54
54
  CORE_ENV_PREFIX = "OPENBOX_LANGGRAPH"
55
55
  SDK_ENGINE = "langgraph"
56
56
  SDK_LANGUAGE = "python"
57
- SDK_PACKAGE_VERSION = "1.1.0"
57
+ SDK_PACKAGE_VERSION = "1.2.0"
58
58
 
59
59
  __all__ = [
60
60
  "CORE_ENV_PREFIX",
@@ -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, or
611
- (for `ainvoke`) an approved hook-approval retry — see the `finally`
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
- The abort-mark clear runs UNCONDITIONALLY for every swept tool record
639
- (not gated on `record.abort_marked`): a REQUIRE_APPROVAL raised by the
640
- callback can propagate through ANY entry point (`ainvoke` polls and
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
- # Outermost `finally` on purpose: an approval retry re-runs the
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.1.0
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. The SDK resumes or raises `ApprovalRejectedError` accordingly.
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
  |---|---|---|---|
@@ -1,14 +1,15 @@
1
- openbox_langgraph/__init__.py,sha256=J-3oOgnRjF5ixC8r90vthDAVdotGI-fyg84byXvl4xQ,4142
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=Asq9iw6BcazzIgRpyqP4KJOFris2ferOoz0u63EAtNo,26835
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=xv--ptTjoeAD7bo7IvJOhP_RRnKTkoAr06VY945yYBE,11418
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=NRcjbbYnE-1sOaFM_rcmNpcAkufqTpRbn1L2WB0GXWk,8600
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=Ibp4wcBznlAh4qtH2nNiWGzCUX4YFZhrJF7pTSpLZaA,106043
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.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,,
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,,