openbox-temporal-sdk-python 1.2.0__py3-none-any.whl → 1.3.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/__init__.py CHANGED
@@ -6,45 +6,43 @@
6
6
  # it crashes the workflow sandbox as a circular import, lazily it recurses
7
7
  # unboundedly from build_auth_headers on every evaluate. Keep in sync with
8
8
  # pyproject.toml on release.
9
- __version__ = "1.2.0"
10
-
11
- from .worker import create_openbox_worker
12
-
13
- from .config import (
14
- initialize,
15
- get_global_config,
16
- GovernanceConfig,
17
- )
9
+ __version__ = "1.3.0"
18
10
 
11
+ from .config import GovernanceConfig, get_global_config, initialize
19
12
  from .errors import (
20
- OpenBoxError,
21
- OpenBoxConfigError,
22
- OpenBoxAuthError,
23
- OpenBoxNetworkError,
24
- OpenBoxInsecureURLError,
25
- OpenBoxSigningError,
26
- GovernanceBlockedError,
27
- GovernanceHaltError,
28
- GovernanceAPIError,
29
- GuardrailsValidationError,
30
13
  ApprovalExpiredError,
31
14
  ApprovalRejectedError,
32
15
  ApprovalTimeoutError,
16
+ GovernanceAPIError,
17
+ GovernanceBlockedError,
18
+ GovernanceHaltError,
19
+ GuardrailsValidationError,
20
+ OpenBoxAuthError,
21
+ OpenBoxConfigError,
22
+ OpenBoxError,
23
+ OpenBoxInsecureURLError,
24
+ OpenBoxNetworkError,
25
+ OpenBoxSigningError,
33
26
  extract_governance_error,
34
27
  map_signing_error,
35
28
  )
36
29
 
37
- from .types import (
38
- Verdict,
39
- WorkflowEventType,
40
- GovernanceVerdictResponse,
41
- GuardrailsCheckResult,
42
- )
43
-
44
30
  # Multi-agent primitives (sandbox-safe — only imports temporalio.workflow eagerly;
45
31
  # signing/HTTP routed lazily through the governance activity).
46
32
  from .multi_agent import emit_handoff
47
33
 
34
+ # Retryable-BLOCK restart envelope (sandbox-safe: pure stdlib + base contracts).
35
+ from .retryable_block import (
36
+ GOVERNANCE_RETRYABLE_BLOCK_SCHEMA_VERSION,
37
+ RetryableBlockRequest,
38
+ )
39
+ from .types import (
40
+ GovernanceVerdictResponse,
41
+ GuardrailsCheckResult,
42
+ Verdict,
43
+ WorkflowEventType,
44
+ )
45
+ from .worker import create_openbox_worker
48
46
  from .workflow_interceptor import GovernanceInterceptor
49
47
 
50
48
  try:
@@ -54,11 +52,9 @@ try:
54
52
  except ImportError:
55
53
  pass # temporalio < 1.23.0, plugin not available
56
54
 
57
- from .verdict_handler import enforce_verdict, VerdictEnforcementResult
58
-
59
- from .hitl import handle_approval_response, raise_approval_pending, should_skip_hitl
60
-
61
55
  from .client import GovernanceClient
56
+ from .hitl import handle_approval_response, raise_approval_pending, should_skip_hitl
57
+ from .verdict_handler import VerdictEnforcementResult, enforce_verdict
62
58
 
63
59
  # NOTE: ActivityGovernanceInterceptor is NOT imported here because it imports
64
60
  # OpenTelemetry which uses importlib_metadata -> os.stat, causing sandbox issues.
@@ -114,6 +110,8 @@ __all__ = [
114
110
  "WorkflowEventType",
115
111
  "GovernanceVerdictResponse",
116
112
  "GuardrailsCheckResult",
113
+ "RetryableBlockRequest",
114
+ "GOVERNANCE_RETRYABLE_BLOCK_SCHEMA_VERSION",
117
115
  "emit_handoff",
118
116
  "GovernanceInterceptor",
119
117
  "enforce_verdict",
openbox/activities.py CHANGED
@@ -21,18 +21,16 @@ executes. This ensures timestamps are generated in activity context (non-determi
21
21
  code allowed) rather than workflow context (must be deterministic).
22
22
  """
23
23
 
24
- import httpx
25
24
  import logging
26
25
  from datetime import datetime, timezone
27
- from typing import Dict, Any, Optional
28
-
29
-
30
- from .types import rfc3339_now as _rfc3339_now
26
+ from typing import Any, Dict, Optional
31
27
 
28
+ import httpx
32
29
  from temporalio import activity
33
30
  from temporalio.exceptions import ApplicationError
34
31
 
35
- from .types import Verdict
32
+ from .types import GovernanceVerdictResponse, Verdict
33
+ from .types import rfc3339_now as _rfc3339_now
36
34
 
37
35
  logger = logging.getLogger(__name__)
38
36
 
@@ -201,12 +199,26 @@ class GovernanceActivities:
201
199
  )
202
200
 
203
201
  data = response.json()
204
- verdict = Verdict.from_string(
205
- data.get("verdict") or data.get("action", "continue")
206
- )
207
- reason = data.get("reason")
208
- policy_id = data.get("policy_id")
209
- risk_score = data.get("risk_score", 0.0)
202
+ parsed = GovernanceVerdictResponse.from_dict(data)
203
+ verdict = parsed.verdict
204
+ reason = parsed.reason
205
+ policy_id = parsed.policy_id
206
+ risk_score = parsed.risk_score
207
+
208
+ # A BLOCK carrying a valid retry plan restarts the workflow run
209
+ # instead of merely failing this activity — check for it before
210
+ # the plain BLOCK/HALT stop handling below ever sees the verdict.
211
+ from .errors import GOVERNANCE_RETRYABLE_BLOCK_ERROR_TYPE
212
+ from .retryable_block import retryable_block_request
213
+
214
+ retry_req = retryable_block_request(parsed, event_type=event_type)
215
+ if retry_req is not None:
216
+ raise ApplicationError(
217
+ "Governance requested workflow restart",
218
+ retry_req.to_dict(),
219
+ type=GOVERNANCE_RETRYABLE_BLOCK_ERROR_TYPE,
220
+ non_retryable=True,
221
+ )
210
222
 
211
223
  if verdict.should_stop():
212
224
  result = await _handle_stop_verdict(
@@ -11,12 +11,11 @@ IMPORTANT: Activities CAN use datetime/time and make HTTP calls directly.
11
11
  This is different from workflow interceptors which must maintain determinism.
12
12
  """
13
13
 
14
- from typing import Optional, Any, List
15
14
  import dataclasses
16
- from dataclasses import asdict, is_dataclass, fields
17
- import time
18
15
  import json
19
-
16
+ import time
17
+ from dataclasses import asdict, fields, is_dataclass
18
+ from typing import Any, List, NoReturn, Optional
20
19
 
21
20
  from .types import rfc3339_now as _rfc3339_now
22
21
 
@@ -63,27 +62,44 @@ def _update_list_items(current_list: list, new_list: list, _logger=None) -> None
63
62
  current_list[i] = new_item
64
63
 
65
64
 
65
+ from opentelemetry import trace
66
66
  from temporalio import activity
67
67
  from temporalio.worker import (
68
- Interceptor,
69
68
  ActivityInboundInterceptor,
70
69
  ExecuteActivityInput,
70
+ Interceptor,
71
71
  )
72
- from opentelemetry import trace
73
72
 
73
+ from .activities import _terminate_workflow_for_halt
74
+ from .client import GovernanceClient
74
75
  from .config import GovernanceConfig
75
76
  from .core_adapter import core_activity_scope, get_core_context_store
76
- from .governance_state import TemporalGovernanceState
77
- from .types import (
78
- WorkflowEventType,
79
- GovernanceVerdictResponse,
80
- Verdict,
77
+ from .errors import (
78
+ GOVERNANCE_RETRYABLE_BLOCK_ERROR_TYPE,
79
+ GovernanceBlockedError,
80
+ GovernanceHaltError,
81
+ GuardrailsValidationError,
81
82
  )
83
+ from .governance_state import TemporalGovernanceState
82
84
  from .multi_agent import read_session_from_header
83
- from .activities import _terminate_workflow_for_halt
85
+ from .retryable_block import RetryableBlockRequest, retryable_block_request
86
+ from .types import GovernanceVerdictResponse, Verdict, WorkflowEventType
84
87
  from .verdict_handler import enforce_verdict
85
- from .errors import GovernanceBlockedError, GovernanceHaltError, GuardrailsValidationError
86
- from .client import GovernanceClient
88
+
89
+
90
+ def _raise_retryable_block(req: RetryableBlockRequest) -> NoReturn:
91
+ """Raise the stable, versioned ``GovernanceRetryableBlock`` ApplicationError
92
+ for a valid retryable-BLOCK directive. Non-retryable — the workflow
93
+ interceptor catches this stable type and Continue-As-News with the
94
+ replacement input."""
95
+ from temporalio.exceptions import ApplicationError
96
+
97
+ raise ApplicationError(
98
+ "Governance requested workflow restart",
99
+ req.to_dict(),
100
+ type=GOVERNANCE_RETRYABLE_BLOCK_ERROR_TYPE,
101
+ non_retryable=True,
102
+ )
87
103
 
88
104
 
89
105
  def _serialize_value(value: Any) -> Any:
@@ -94,6 +110,12 @@ def _serialize_value(value: Any) -> Any:
94
110
  return value
95
111
  if isinstance(value, bytes):
96
112
  return _serialize_bytes(value)
113
+ model_dump = getattr(value, "model_dump", None)
114
+ if callable(model_dump):
115
+ try:
116
+ return model_dump(mode="json")
117
+ except Exception:
118
+ pass
97
119
  if is_dataclass(value) and not isinstance(value, type):
98
120
  return asdict(value)
99
121
  if isinstance(value, (list, tuple)):
@@ -111,6 +133,7 @@ def _serialize_bytes(value: bytes) -> str:
111
133
  return value.decode("utf-8")
112
134
  except Exception:
113
135
  import base64
136
+
114
137
  return base64.b64encode(value).decode("ascii")
115
138
 
116
139
 
@@ -238,35 +261,56 @@ class _ActivityInterceptor(ActivityInboundInterceptor):
238
261
  )
239
262
  except Exception:
240
263
  # The activity (or a hook) raised, so _handle_completion is skipped. A
241
- # completed-hook HALT recorded during user code is a kill-switch and
242
- # must STILL reach the terminate path failing the activity does not
243
- # halt the workflow. Consume the completed-stop here too (also clears
244
- # it, so it can never strand/leak on the exception path).
264
+ # completed-hook stop recorded during user code must still be enforced:
265
+ # HALT reaches the terminate path and raises GovernanceHalt, and a
266
+ # retryable request raises GovernanceRetryableBlock either REPLACES
267
+ # the original exception (completed-hook priority HALT > retryable >
268
+ # original exception). A plain completed BLOCK is a no-op, so the
269
+ # `raise` below re-propagates the original exception unchanged. Consume
270
+ # the completed-stop either way (also clears it, so it can never
271
+ # strand/leak on the exception path).
245
272
  await self._consume_completed_halt(info)
246
273
  raise
247
274
 
248
275
  result = await self._handle_completion(
249
- info, status, error, start_time, end_time,
250
- activity_input, activity_output, result,
276
+ info,
277
+ status,
278
+ error,
279
+ start_time,
280
+ end_time,
281
+ activity_input,
282
+ activity_output,
283
+ result,
251
284
  session_id,
252
285
  )
253
286
 
254
287
  return result
255
288
 
256
289
  async def _consume_completed_halt(self, info) -> None:
257
- """Exception-path safety net: a completed-hook HALT recorded during user
258
- code must terminate the workflow even when the activity itself raised.
259
- Clears the completed-stop and the base abort flag so nothing strands."""
290
+ """Exception-path safety net: a completed-hook stop recorded during user
291
+ code must still be enforced even though the activity itself raised, so
292
+ _handle_completion (the success path) never runs. Priority: HALT raises
293
+ GovernanceHalt (terminating the workflow and REPLACING the original
294
+ exception); a retryable request raises GovernanceRetryableBlock (also
295
+ replacing the original exception with the restart request); a plain
296
+ completed BLOCK is a no-op here, so the caller's re-raise propagates the
297
+ original exception unchanged. Clears the completed-stop and the base abort
298
+ flag so nothing strands."""
260
299
  stop = self._state.take_completed_stop(
261
300
  info.workflow_id, info.workflow_run_id, info.activity_id
262
301
  )
263
302
  get_core_context_store().clear_activity_aborted(
264
303
  info.workflow_id, info.activity_id
265
304
  )
266
- if stop is not None and stop[0] is Verdict.HALT:
305
+ if stop is None:
306
+ return
307
+ if stop.verdict is Verdict.HALT:
267
308
  await _terminate_workflow_for_halt(
268
- info.workflow_id, stop[1] or "Governance halt"
309
+ info.workflow_id, stop.reason or "Governance halt"
269
310
  )
311
+ return
312
+ if stop.request is not None:
313
+ _raise_retryable_block(stop.request)
270
314
 
271
315
  async def _check_pending_verdicts(self, info) -> None:
272
316
  """Enforce a SignalReceived BLOCK/HALT recorded for this run by the
@@ -292,6 +336,7 @@ class _ActivityInterceptor(ActivityInboundInterceptor):
292
336
  await _terminate_workflow_for_halt(workflow_id, reason)
293
337
  else:
294
338
  from temporalio.exceptions import ApplicationError
339
+
295
340
  raise ApplicationError(
296
341
  f"Governance blocked: {reason}",
297
342
  type="GovernanceBlock",
@@ -329,9 +374,7 @@ class _ActivityInterceptor(ActivityInboundInterceptor):
329
374
  info.activity_id,
330
375
  )
331
376
  if approved:
332
- activity.logger.info(
333
- f"Approval granted for workflow_id={info.workflow_id}"
334
- )
377
+ activity.logger.info(f"Approval granted for workflow_id={info.workflow_id}")
335
378
  self._state.clear_pending_approval(
336
379
  info.workflow_id, info.workflow_run_id, info.activity_id
337
380
  )
@@ -362,7 +405,21 @@ class _ActivityInterceptor(ActivityInboundInterceptor):
362
405
  self, verdict_response: GovernanceVerdictResponse, info, context: str
363
406
  ) -> None:
364
407
  """Enforce a governance verdict (HITL, BLOCK, HALT, guardrails)."""
365
- from .hitl import should_skip_hitl, raise_approval_pending
408
+ from .hitl import raise_approval_pending, should_skip_hitl
409
+
410
+ # An exact BLOCK with a valid retry plan requests a workflow restart and
411
+ # takes priority over the generic BLOCK/HALT/guardrails mapping below —
412
+ # checked before enforce_verdict() so it is never first converted to a
413
+ # plain GovernanceBlockedError. These are REAL activity-lifecycle events
414
+ # (not hooks), so hook_trigger stays the default False.
415
+ event_type = (
416
+ WorkflowEventType.ACTIVITY_STARTED.value
417
+ if context == "activity_start"
418
+ else WorkflowEventType.ACTIVITY_COMPLETED.value
419
+ )
420
+ req = retryable_block_request(verdict_response, event_type=event_type)
421
+ if req is not None:
422
+ _raise_retryable_block(req)
366
423
 
367
424
  try:
368
425
  verdict_result = enforce_verdict(verdict_response, context)
@@ -384,6 +441,7 @@ class _ActivityInterceptor(ActivityInboundInterceptor):
384
441
  await _terminate_workflow_for_halt(info.workflow_id, str(e))
385
442
  except GovernanceBlockedError as e:
386
443
  from temporalio.exceptions import ApplicationError
444
+
387
445
  raise ApplicationError(
388
446
  f"Governance blocked: {e.reason}",
389
447
  type="GovernanceBlock",
@@ -391,6 +449,7 @@ class _ActivityInterceptor(ActivityInboundInterceptor):
391
449
  )
392
450
  except GuardrailsValidationError as e:
393
451
  from temporalio.exceptions import ApplicationError
452
+
394
453
  activity.logger.info(f"Guardrails validation failed: {e}")
395
454
  raise ApplicationError(
396
455
  f"Guardrails validation failed: {e}",
@@ -510,8 +569,16 @@ class _ActivityInterceptor(ActivityInboundInterceptor):
510
569
  return result, status, error, activity_output, end_time
511
570
 
512
571
  async def _handle_completion(
513
- self, info, status, error, start_time, end_time,
514
- activity_input, activity_output, result, multi_agent_session_id=None,
572
+ self,
573
+ info,
574
+ status,
575
+ error,
576
+ start_time,
577
+ end_time,
578
+ activity_input,
579
+ activity_output,
580
+ result,
581
+ multi_agent_session_id=None,
515
582
  ) -> Any:
516
583
  """Send ActivityCompleted, enforce verdict, apply output redaction."""
517
584
  store = get_core_context_store()
@@ -525,10 +592,16 @@ class _ActivityInterceptor(ActivityInboundInterceptor):
525
592
  base_aborted = store.is_activity_aborted(info.workflow_id, info.activity_id)
526
593
  store.clear_activity_aborted(info.workflow_id, info.activity_id)
527
594
 
528
- if completed_stop is not None and completed_stop[0] is Verdict.HALT:
529
- await _terminate_workflow_for_halt(
530
- info.workflow_id, completed_stop[1] or "Governance halt"
531
- )
595
+ if completed_stop is not None:
596
+ if completed_stop.verdict is Verdict.HALT:
597
+ await _terminate_workflow_for_halt(
598
+ info.workflow_id, completed_stop.reason or "Governance halt"
599
+ )
600
+ elif completed_stop.request is not None:
601
+ # A retryable request outranks the plain skip-completed-event
602
+ # behavior below — raise the restart request now that user code
603
+ # has already returned.
604
+ _raise_retryable_block(completed_stop.request)
532
605
 
533
606
  was_aborted = base_aborted or completed_stop is not None
534
607
 
openbox/config.py CHANGED
@@ -10,17 +10,16 @@ linecache -> os.stat which triggers Temporal sandbox restrictions.
10
10
 
11
11
  import re
12
12
  from dataclasses import dataclass, field
13
- from typing import Set, Optional
13
+ from typing import Optional, Set
14
14
 
15
15
  from openbox_core.errors import OpenBoxConfigError as CoreOpenBoxConfigError
16
- from openbox_core.identity import (
17
- AGENT_DID_PREFIX as CORE_AGENT_DID_PREFIX,
18
- load_ed25519_seed as load_core_ed25519_seed,
19
- validate_agent_did as validate_core_agent_did,
20
- )
21
16
 
22
- # NOTE: urllib and logging imports are lazy to avoid Temporal sandbox restrictions.
23
- # Both use os.stat internally which triggers sandbox errors.
17
+ # NOTE: urllib, logging, and openbox_core.identity imports are lazy to avoid
18
+ # Temporal sandbox restrictions. urllib/logging use os.stat internally; identity
19
+ # pulls cryptography. None may load on a workflow-sandbox import path
20
+ # (openbox/__init__ → worker → config), so identity is imported inside the
21
+ # functions that need it and via module __getattr__ for AGENT_DID_PREFIX.
22
+ # Guarded by tests/test_workflow_sandbox_import_safety.py.
24
23
 
25
24
 
26
25
  def _get_logger():
@@ -33,15 +32,24 @@ def _get_logger():
33
32
  # API key format pattern (obx_live_... or obx_test_...)
34
33
  API_KEY_PATTERN = re.compile(r"^obx_(live|test)_\w+$")
35
34
 
36
- # Backward-compatible public constant, owned by the base SDK.
37
- AGENT_DID_PREFIX = CORE_AGENT_DID_PREFIX
35
+
36
+ # Backward-compatible public constant, owned by the base SDK. Resolved lazily via
37
+ # module __getattr__ (PEP 562) so a workflow-sandbox import of openbox.config never
38
+ # eagerly loads openbox_core.identity (and its cryptography dependency).
39
+ def __getattr__(name: str):
40
+ if name == "AGENT_DID_PREFIX":
41
+ from openbox_core.identity import AGENT_DID_PREFIX as _prefix
42
+
43
+ return _prefix
44
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
45
+
38
46
 
39
47
  # Re-export from errors.py for backward compatibility
40
48
  from .errors import ( # noqa: F401
41
- OpenBoxConfigError,
42
49
  OpenBoxAuthError,
43
- OpenBoxNetworkError,
50
+ OpenBoxConfigError,
44
51
  OpenBoxInsecureURLError,
52
+ OpenBoxNetworkError,
45
53
  )
46
54
 
47
55
  # GovernanceConfig - Configuration for interceptors
@@ -117,6 +125,22 @@ class GovernanceConfig:
117
125
  # Temporal currently uses its native retry backoff for HITL polling.
118
126
  hitl_poll_interval_ms: int = 5000
119
127
 
128
+ # Maximum Continue-As-New restarts a retryable BLOCK may trigger across the
129
+ # whole workflow chain (bounded input-remediation loop). Applies uniformly to
130
+ # every event origin; must be >= 1.
131
+ max_retryable_block_restarts: int = 3
132
+
133
+ def __post_init__(self) -> None:
134
+ # GovernanceConfig is public and can be handed directly to an interceptor,
135
+ # bypassing the factory/plugin — so validation lives on the dataclass (the
136
+ # single source of truth), not only at the call sites. Pure + sandbox-safe:
137
+ # raises the already-imported OpenBoxConfigError, no logging/IO.
138
+ if self.max_retryable_block_restarts < 1:
139
+ raise OpenBoxConfigError(
140
+ "max_retryable_block_restarts must be >= 1 "
141
+ f"(got {self.max_retryable_block_restarts})"
142
+ )
143
+
120
144
 
121
145
  # Global Configuration Singleton
122
146
 
@@ -128,6 +152,8 @@ def _validate_api_key_format(api_key: str) -> bool:
128
152
 
129
153
  def _validate_did(agent_did: str) -> None:
130
154
  """Validate agent DID format through the shared base SDK."""
155
+ from openbox_core.identity import validate_agent_did as validate_core_agent_did
156
+
131
157
  try:
132
158
  validate_core_agent_did(agent_did)
133
159
  except CoreOpenBoxConfigError as exc:
@@ -150,6 +176,8 @@ def resolve_signing_defaults(agent_did, signer):
150
176
 
151
177
  def _load_ed25519_seed(agent_private_key: str):
152
178
  """Decode and load an Ed25519 signer through the shared base SDK."""
179
+ from openbox_core.identity import load_ed25519_seed as load_core_ed25519_seed
180
+
153
181
  try:
154
182
  return load_core_ed25519_seed(agent_private_key)
155
183
  except CoreOpenBoxConfigError as exc:
@@ -218,8 +246,8 @@ def _validate_api_key_with_server(
218
246
  urllib.request uses os.stat internally which triggers sandbox errors.
219
247
  """
220
248
  # Lazy imports to avoid sandbox restrictions
221
- from urllib.request import Request, urlopen
222
249
  from urllib.error import HTTPError, URLError
250
+ from urllib.request import Request, urlopen
223
251
 
224
252
  # Build signed (or plain) headers via the single source of truth.
225
253
  from .request_signing import prepare_signed_request
openbox/core_adapter.py CHANGED
@@ -9,7 +9,10 @@ effects and never builds hook payloads or evaluates hook events itself:
9
9
  ``ApprovalPending`` (Temporal's native HITL retry loop) + a pending marker in
10
10
  ``TemporalGovernanceState``; completed-hook BLOCK/HALT -> recorded in
11
11
  ``TemporalGovernanceState`` (run-scoped) for the activity interceptor to
12
- surface after user code returns.
12
+ surface after user code returns. An exact BLOCK carrying a valid retry plan
13
+ is checked FIRST at every one of these seams and raises the stable, versioned
14
+ ``GovernanceRetryableBlock`` instead — HALT still wins because the base
15
+ normalizer never surfaces a directive for it.
13
16
  - ``core_activity_scope`` binds the shared ``ActivityContext`` around activity
14
17
  execution with a GUARANTEED try/finally reset.
15
18
 
@@ -31,8 +34,10 @@ from openbox_core.contracts.results import EvaluationResult, Verdict
31
34
  from .errors import (
32
35
  GOVERNANCE_BLOCK_ERROR_TYPE,
33
36
  GOVERNANCE_HALT_ERROR_TYPE,
37
+ GOVERNANCE_RETRYABLE_BLOCK_ERROR_TYPE,
34
38
  )
35
39
  from .governance_state import TemporalGovernanceState
40
+ from .retryable_block import RetryableBlockRequest, retryable_block_request
36
41
 
37
42
  logger = logging.getLogger(__name__)
38
43
 
@@ -78,7 +83,9 @@ class TemporalFrameworkAdapter:
78
83
  self._state = state
79
84
  self._hitl_enabled = hitl_enabled
80
85
  self._skip_hitl_activity_types = skip_hitl_activity_types or set()
81
- self._store = context_store if context_store is not None else _core_context_store
86
+ self._store = (
87
+ context_store if context_store is not None else _core_context_store
88
+ )
82
89
 
83
90
  async def handle_approval(self, result: EvaluationResult) -> None:
84
91
  """Async hook REQUIRE_APPROVAL -> Temporal's retry-based HITL loop.
@@ -125,9 +132,25 @@ class TemporalFrameworkAdapter:
125
132
  raise_approval_pending(result.reason or "Approval required")
126
133
 
127
134
  def raise_lifecycle_blocked(self, result: EvaluationResult) -> NoReturn:
135
+ # An exact BLOCK with a valid retry plan requests a workflow restart and
136
+ # is checked before the generic BLOCK/HALT mapping; HALT still wins
137
+ # because retryable_block_request never surfaces a directive for it.
138
+ req = retryable_block_request(result, event_type="ActivityStarted")
139
+ if req is not None:
140
+ self._raise_retryable_block(req)
128
141
  self._raise_application_error(result)
129
142
 
130
143
  def raise_hook_blocked(self, result: EvaluationResult) -> NoReturn:
144
+ # Started hooks are wire-represented as ActivityStarted + hook_trigger=
145
+ # True (the stage rides in hook_stage) — same retryable-first ordering.
146
+ req = retryable_block_request(
147
+ result,
148
+ event_type="ActivityStarted",
149
+ hook_trigger=True,
150
+ hook_stage="started",
151
+ )
152
+ if req is not None:
153
+ self._raise_retryable_block(req)
131
154
  self._raise_application_error(result)
132
155
 
133
156
  def on_completed_hook_result(
@@ -135,17 +158,31 @@ class TemporalFrameworkAdapter:
135
158
  ) -> None:
136
159
  """Completed verdicts affect FUTURE execution only (the operation already
137
160
  ran). Record a BLOCK/HALT run-scoped so the activity interceptor can skip
138
- a duplicate completed event (BLOCK) or reach the terminate path (HALT)
139
- after user code returns. Without a resolved context there is no run to key
140
- on the base ContextStore still carries the within-activity abort flag."""
161
+ a duplicate completed event (plain BLOCK), reach the terminate path
162
+ (HALT), or raise a retryable-BLOCK restart request (BLOCK with a valid
163
+ retry plan) after user code returns. Without a resolved context there is
164
+ no run to key on — the base ContextStore still carries the
165
+ within-activity abort flag.
166
+
167
+ Every hook is wire-represented as ``event_type="ActivityStarted"`` with
168
+ ``hook_trigger=True`` — the completed stage rides ONLY in ``hook_stage``,
169
+ never by flipping ``event_type`` to ``ActivityCompleted``.
170
+ """
141
171
  if not result.verdict.should_stop() or context is None:
142
172
  return
173
+ request = retryable_block_request(
174
+ result,
175
+ event_type="ActivityStarted",
176
+ hook_trigger=True,
177
+ hook_stage="completed",
178
+ )
143
179
  self._state.record_completed_stop(
144
180
  context.workflow_id or "",
145
181
  context.run_id or "",
146
182
  context.activity_id or "",
147
183
  result.verdict,
148
184
  result.reason,
185
+ request,
149
186
  )
150
187
 
151
188
  @staticmethod
@@ -165,6 +202,19 @@ class TemporalFrameworkAdapter:
165
202
  non_retryable=True,
166
203
  )
167
204
 
205
+ @staticmethod
206
+ def _raise_retryable_block(req: RetryableBlockRequest) -> NoReturn:
207
+ """Raise the stable, versioned ``GovernanceRetryableBlock`` the workflow
208
+ interceptor catches to Continue-As-New with the replacement input."""
209
+ from temporalio.exceptions import ApplicationError
210
+
211
+ raise ApplicationError(
212
+ "Governance requested workflow restart",
213
+ req.to_dict(),
214
+ type=GOVERNANCE_RETRYABLE_BLOCK_ERROR_TYPE,
215
+ non_retryable=True,
216
+ )
217
+
168
218
 
169
219
  def build_core_activity_context(
170
220
  info: Any,
openbox/errors.py CHANGED
@@ -36,6 +36,15 @@ GOVERNANCE_API_ERROR_TYPE: Final[str] = "GovernanceAPIError"
36
36
  # Legacy alias; kept for histories predating the rename.
37
37
  GOVERNANCE_STOP_ERROR_TYPE: Final[str] = "GovernanceStop"
38
38
 
39
+ # Retryable-BLOCK restart transport. A governance BLOCK carrying a valid retry
40
+ # plan crosses the activity/workflow boundary as a non-retryable ApplicationError
41
+ # of this stable type; the workflow interceptor catches it and Continue-As-News.
42
+ GOVERNANCE_RETRYABLE_BLOCK_ERROR_TYPE: Final[str] = "GovernanceRetryableBlock"
43
+ # Raised when the bounded restart chain would exceed max_retryable_block_restarts.
44
+ GOVERNANCE_RETRY_LIMIT_EXCEEDED_ERROR_TYPE: Final[str] = "GovernanceRetryLimitExceeded"
45
+ # Raised when replacement input cannot be converted for Continue-As-New.
46
+ GOVERNANCE_RETRY_INPUT_INVALID_ERROR_TYPE: Final[str] = "GovernanceRetryInputInvalid"
47
+
39
48
 
40
49
  class OpenBoxError(Exception):
41
50
  """Base class for all OpenBox SDK errors."""
@@ -105,7 +114,9 @@ _SIGNING_REASON_MESSAGES: dict[str, str] = {
105
114
  }
106
115
 
107
116
 
108
- def map_signing_error(reason_code: str | None, fallback: str = "") -> OpenBoxSigningError:
117
+ def map_signing_error(
118
+ reason_code: str | None, fallback: str = ""
119
+ ) -> OpenBoxSigningError:
109
120
  """Map a Core signing reason code to an actionable OpenBoxSigningError.
110
121
 
111
122
  Unknown/empty codes fall back to a generic message (optionally augmented with