openbox-langgraph-sdk-python 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,485 @@
1
+ """OpenBox LangGraph SDK — Core types mirroring sdk-langgraph/src/types.ts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from datetime import UTC, datetime
7
+ from enum import StrEnum
8
+ from typing import Any, Literal
9
+
10
+ # ═══════════════════════════════════════════════════════════════════
11
+ # Verdict
12
+ # ═══════════════════════════════════════════════════════════════════
13
+
14
+ class Verdict(StrEnum):
15
+ """5-tier graduated response. Priority: HALT > BLOCK > REQUIRE_APPROVAL > CONSTRAIN > ALLOW"""
16
+
17
+ ALLOW = "allow"
18
+ CONSTRAIN = "constrain"
19
+ REQUIRE_APPROVAL = "require_approval"
20
+ BLOCK = "block"
21
+ HALT = "halt"
22
+
23
+ @classmethod
24
+ def from_string(cls, value: str | None) -> Verdict:
25
+ """Parse verdict string with v1.0 compat aliases (continue→ALLOW, stop→HALT)."""
26
+ if value is None:
27
+ return cls.ALLOW
28
+ normalized = value.lower().replace("-", "_")
29
+ if normalized == "continue":
30
+ return cls.ALLOW
31
+ if normalized == "stop":
32
+ return cls.HALT
33
+ if normalized in ("require_approval", "request_approval"):
34
+ return cls.REQUIRE_APPROVAL
35
+ try:
36
+ return cls(normalized)
37
+ except ValueError:
38
+ return cls.ALLOW
39
+
40
+ @property
41
+ def priority(self) -> int:
42
+ """Priority for aggregation: HALT=5, BLOCK=4, REQUIRE_APPROVAL=3, CONSTRAIN=2, ALLOW=1"""
43
+ return {
44
+ Verdict.ALLOW: 1, Verdict.CONSTRAIN: 2, Verdict.REQUIRE_APPROVAL: 3,
45
+ Verdict.BLOCK: 4, Verdict.HALT: 5,
46
+ }[self]
47
+
48
+ @classmethod
49
+ def highest_priority(cls, verdicts: list[Verdict]) -> Verdict:
50
+ """Get highest priority verdict from list. Returns ALLOW if empty."""
51
+ return max(verdicts, key=lambda v: v.priority) if verdicts else cls.ALLOW
52
+
53
+ def should_stop(self) -> bool:
54
+ """True if BLOCK or HALT."""
55
+ return self in (Verdict.BLOCK, Verdict.HALT)
56
+
57
+ def requires_approval(self) -> bool:
58
+ """True if REQUIRE_APPROVAL."""
59
+ return self == Verdict.REQUIRE_APPROVAL
60
+
61
+
62
+ # Module-level aliases for backward compat and convenience
63
+ def verdict_from_string(value: str | None) -> Verdict:
64
+ """Parse a verdict string — delegates to Verdict.from_string()."""
65
+ return Verdict.from_string(value)
66
+
67
+
68
+ def verdict_priority(v: Verdict) -> int:
69
+ """Return the numeric priority of a verdict (higher = more restrictive)."""
70
+ return v.priority
71
+
72
+
73
+ def highest_priority_verdict(verdicts: list[Verdict]) -> Verdict:
74
+ """Return the highest-priority (most restrictive) verdict from a list."""
75
+ return Verdict.highest_priority(verdicts)
76
+
77
+
78
+ def verdict_should_stop(v: Verdict) -> bool:
79
+ """Return True if the verdict requires stopping execution immediately."""
80
+ return v.should_stop()
81
+
82
+
83
+ def verdict_requires_approval(v: Verdict) -> bool:
84
+ """Return True if the verdict requires human approval before continuing."""
85
+ return v.requires_approval()
86
+
87
+
88
+ # ═══════════════════════════════════════════════════════════════════
89
+ # Workflow Span Buffer (used by WorkflowSpanProcessor)
90
+ # ═══════════════════════════════════════════════════════════════════
91
+
92
+ @dataclass
93
+ class WorkflowSpanBuffer:
94
+ """Buffer for workflow governance state (used by WorkflowSpanProcessor)."""
95
+
96
+ workflow_id: str
97
+ run_id: str = ""
98
+ workflow_type: str = ""
99
+ verdict: Verdict | None = None
100
+ verdict_reason: str | None = None
101
+
102
+
103
+ # ═══════════════════════════════════════════════════════════════════
104
+ # LangGraph v2 stream event
105
+ # ═══════════════════════════════════════════════════════════════════
106
+
107
+ # ═══════════════════════════════════════════════════════════════════
108
+ # WorkflowEventType (server-side event labels)
109
+ # ═══════════════════════════════════════════════════════════════════
110
+
111
+ class WorkflowEventType(StrEnum):
112
+ """Workflow lifecycle events for governance (matches OpenBox Core wire format)."""
113
+
114
+ WORKFLOW_STARTED = "WorkflowStarted"
115
+ WORKFLOW_COMPLETED = "WorkflowCompleted"
116
+ WORKFLOW_FAILED = "WorkflowFailed"
117
+ SIGNAL_RECEIVED = "SignalReceived"
118
+ ACTIVITY_STARTED = "ActivityStarted"
119
+ ACTIVITY_COMPLETED = "ActivityCompleted"
120
+
121
+
122
+ LangGraphEventType = Literal[
123
+ "on_chain_start",
124
+ "on_chain_end",
125
+ "on_chain_stream",
126
+ "on_chat_model_start",
127
+ "on_chat_model_end",
128
+ "on_chat_model_stream",
129
+ "on_tool_start",
130
+ "on_tool_end",
131
+ "on_retriever_start",
132
+ "on_retriever_end",
133
+ ]
134
+
135
+ LangChainEventType = Literal[
136
+ "ChainStarted",
137
+ "ChainCompleted",
138
+ "ChainFailed",
139
+ "ToolStarted",
140
+ "ToolCompleted",
141
+ "ToolFailed",
142
+ "LLMStarted",
143
+ "LLMCompleted",
144
+ "LLMFailed",
145
+ "AgentAction",
146
+ "AgentFinish",
147
+ "RetrieverStarted",
148
+ "RetrieverCompleted",
149
+ "RetrieverFailed",
150
+ ]
151
+
152
+ ServerEventType = Literal[
153
+ "WorkflowStarted",
154
+ "WorkflowCompleted",
155
+ "WorkflowFailed",
156
+ "SignalReceived",
157
+ "ActivityStarted",
158
+ "ActivityCompleted",
159
+ ]
160
+
161
+
162
+ @dataclass
163
+ class LangGraphStreamEvent:
164
+ """Raw LangGraph v2 streaming event from `.astream_events(version='v2')`."""
165
+
166
+ event: str
167
+ name: str
168
+ run_id: str
169
+ metadata: dict[str, Any] = field(default_factory=dict)
170
+ data: dict[str, Any] = field(default_factory=dict)
171
+ tags: list[str] = field(default_factory=list)
172
+ parent_ids: list[str] = field(default_factory=list)
173
+
174
+ @classmethod
175
+ def from_dict(cls, d: dict[str, Any]) -> LangGraphStreamEvent:
176
+ """Construct from a raw dict emitted by LangGraph's event stream."""
177
+ return cls(
178
+ event=d.get("event", ""),
179
+ name=d.get("name", ""),
180
+ run_id=d.get("run_id", ""),
181
+ metadata=d.get("metadata") or {},
182
+ data=d.get("data") or {},
183
+ tags=d.get("tags") or [],
184
+ parent_ids=d.get("parent_ids") or [],
185
+ )
186
+
187
+
188
+ def lang_graph_event_to_server_type(event_type: str) -> ServerEventType | None:
189
+ """Map a LangGraph v2 event type to the server-accepted Temporal equivalent."""
190
+ mapping: dict[str, ServerEventType] = {
191
+ "on_chain_start": "WorkflowStarted",
192
+ "on_chain_end": "WorkflowCompleted",
193
+ "on_chat_model_start": "ActivityStarted",
194
+ "on_chat_model_end": "ActivityCompleted",
195
+ "on_tool_start": "ActivityStarted",
196
+ "on_tool_end": "ActivityCompleted",
197
+ "on_retriever_start": "ActivityStarted",
198
+ "on_retriever_end": "ActivityCompleted",
199
+ }
200
+ return mapping.get(event_type)
201
+
202
+
203
+ def to_server_event_type(t: str) -> ServerEventType:
204
+ """Map a LangChain SDK-internal event type to the server-accepted Temporal equivalent."""
205
+ mapping: dict[str, ServerEventType] = {
206
+ "WorkflowStarted": "WorkflowStarted",
207
+ "WorkflowCompleted": "WorkflowCompleted",
208
+ "WorkflowFailed": "WorkflowFailed",
209
+ "SignalReceived": "SignalReceived",
210
+ "ChainStarted": "WorkflowStarted",
211
+ "ChainCompleted": "WorkflowCompleted",
212
+ "ChainFailed": "WorkflowFailed",
213
+ "ToolStarted": "ActivityStarted",
214
+ "LLMStarted": "ActivityStarted",
215
+ "AgentAction": "ActivityStarted",
216
+ "RetrieverStarted": "ActivityStarted",
217
+ "ToolCompleted": "ActivityCompleted",
218
+ "ToolFailed": "ActivityCompleted",
219
+ "LLMCompleted": "ActivityCompleted",
220
+ "LLMFailed": "ActivityCompleted",
221
+ "AgentFinish": "ActivityCompleted",
222
+ "RetrieverCompleted": "ActivityCompleted",
223
+ "RetrieverFailed": "ActivityCompleted",
224
+ }
225
+ return mapping.get(t, "ActivityCompleted")
226
+
227
+
228
+ # ═══════════════════════════════════════════════════════════════════
229
+ # Governance Event Payload (sent to OpenBox Core)
230
+ # ═══════════════════════════════════════════════════════════════════
231
+
232
+ @dataclass
233
+ class SpanData:
234
+ """HTTP span captured during an activity execution."""
235
+
236
+ span_id: str
237
+ name: str
238
+ trace_id: str | None = None
239
+ parent_span_id: str | None = None
240
+ kind: str | None = None
241
+ start_time: float | None = None
242
+ end_time: float | None = None
243
+ duration_ns: int | None = None
244
+ attributes: dict[str, Any] | None = None
245
+ status: dict[str, str] | None = None
246
+ request_body: str | None = None
247
+ response_body: str | None = None
248
+ request_headers: dict[str, str] | None = None
249
+ response_headers: dict[str, str] | None = None
250
+
251
+
252
+ @dataclass
253
+ class LangChainGovernanceEvent:
254
+ """Governance event payload sent to OpenBox Core `/api/v1/governance/evaluate`."""
255
+
256
+ source: Literal["workflow-telemetry"]
257
+ event_type: str
258
+ workflow_id: str
259
+ run_id: str
260
+ workflow_type: str
261
+ task_queue: str
262
+ timestamp: str
263
+
264
+ # LangGraph routing
265
+ langgraph_node: str | None = None
266
+ langgraph_step: int | None = None
267
+ subagent_name: str | None = None
268
+
269
+ # Activity fields
270
+ activity_id: str | None = None
271
+ activity_type: str | None = None
272
+ activity_input: list[Any] | None = None
273
+ activity_output: Any = None
274
+ workflow_output: Any = None
275
+ spans: list[Any] | None = None
276
+ span_count: int | None = None
277
+ status: str | None = None
278
+ start_time: float | None = None
279
+ end_time: float | None = None
280
+ duration_ms: float | None = None
281
+ error: dict[str, Any] | None = None
282
+
283
+ # Hook trigger flag — True when event is triggered by a new span (e.g., outbound HTTP call)
284
+ hook_trigger: bool = False
285
+
286
+ # LLM/tool extensions
287
+ llm_model: str | None = None
288
+ input_tokens: int | None = None
289
+ output_tokens: int | None = None
290
+ total_tokens: int | None = None
291
+ has_tool_calls: bool | None = None
292
+ finish_reason: str | None = None
293
+ prompt: str | None = None
294
+ completion: str | None = None
295
+ tool_name: str | None = None
296
+ tool_type: str | None = None
297
+ tool_input: Any = None
298
+ parent_run_id: str | None = None
299
+ session_id: str | None = None
300
+ attempt: int | None = None
301
+
302
+ # Signal fields (SignalReceived events)
303
+ signal_name: str | None = None
304
+ signal_args: list[Any] | None = None
305
+
306
+ def to_dict(self) -> dict[str, Any]:
307
+ """Serialize to a dict for the HTTP request body, omitting None values."""
308
+ return {k: v for k, v in self.__dict__.items() if v is not None}
309
+
310
+
311
+ # ═══════════════════════════════════════════════════════════════════
312
+ # Governance Response (from OpenBox Core)
313
+ # ═══════════════════════════════════════════════════════════════════
314
+
315
+ @dataclass
316
+ class GuardrailsReason:
317
+ """A single guardrails violation reason."""
318
+
319
+ type: str
320
+ field: str
321
+ reason: str
322
+
323
+
324
+ @dataclass
325
+ class GuardrailsResult:
326
+ """Guardrails evaluation result embedded in a governance verdict."""
327
+
328
+ input_type: Literal["activity_input", "activity_output"]
329
+ redacted_input: Any
330
+ validation_passed: bool
331
+ reasons: list[GuardrailsReason] = field(default_factory=list)
332
+ raw_logs: dict[str, Any] | None = None
333
+
334
+
335
+ @dataclass
336
+ class GovernanceVerdictResponse:
337
+ """Response from governance API evaluation."""
338
+
339
+ verdict: Verdict
340
+ reason: str | None = None
341
+ # v1.0 fields (kept for compatibility)
342
+ policy_id: str | None = None
343
+ risk_score: float = 0.0
344
+ metadata: dict[str, Any] | None = None
345
+ governance_event_id: str | None = None
346
+ guardrails_result: GuardrailsResult | None = None
347
+ # v1.1 fields
348
+ approval_id: str | None = None
349
+ approval_expiration_time: str | None = None
350
+ trust_tier: str | None = None
351
+ alignment_score: float | None = None
352
+ behavioral_violations: list[str] | None = None
353
+ constraints: list[Any] | None = None
354
+
355
+ @property
356
+ def action(self) -> str:
357
+ """Backward compat: return action string from verdict."""
358
+ if self.verdict == Verdict.ALLOW:
359
+ return "continue"
360
+ if self.verdict == Verdict.HALT:
361
+ return "stop"
362
+ if self.verdict == Verdict.REQUIRE_APPROVAL:
363
+ return "require-approval"
364
+ return self.verdict.value
365
+
366
+ @classmethod
367
+ def from_dict(cls, data: dict[str, Any]) -> GovernanceVerdictResponse:
368
+ """Parse governance response from JSON dict (v1.0 and v1.1 compatible)."""
369
+ guardrails_result: GuardrailsResult | None = None
370
+ if gr := data.get("guardrails_result"):
371
+ reasons = [
372
+ GuardrailsReason(
373
+ type=r.get("type", ""),
374
+ field=r.get("field", ""),
375
+ reason=r.get("reason", ""),
376
+ )
377
+ for r in (gr.get("reasons") or [])
378
+ ]
379
+ guardrails_result = GuardrailsResult(
380
+ input_type=gr.get("input_type", "activity_input"),
381
+ redacted_input=gr.get("redacted_input"),
382
+ validation_passed=gr.get("validation_passed", True) is not False,
383
+ reasons=reasons,
384
+ raw_logs=gr.get("raw_logs"),
385
+ )
386
+
387
+ verdict = Verdict.from_string(data.get("verdict") or data.get("action", "continue"))
388
+
389
+ return cls(
390
+ verdict=verdict,
391
+ reason=data.get("reason"),
392
+ policy_id=data.get("policy_id"),
393
+ risk_score=data.get("risk_score", 0.0),
394
+ metadata=data.get("metadata"),
395
+ governance_event_id=data.get("governance_event_id"),
396
+ guardrails_result=guardrails_result,
397
+ approval_id=data.get("approval_id"),
398
+ approval_expiration_time=data.get("approval_expiration_time"),
399
+ trust_tier=data.get("trust_tier"),
400
+ alignment_score=data.get("alignment_score"),
401
+ behavioral_violations=data.get("behavioral_violations"),
402
+ constraints=data.get("constraints"),
403
+ )
404
+
405
+
406
+ def parse_governance_response(data: dict[str, Any]) -> GovernanceVerdictResponse:
407
+ """Parse a raw dict from OpenBox Core into a `GovernanceVerdictResponse`."""
408
+ return GovernanceVerdictResponse.from_dict(data)
409
+
410
+
411
+ # ═══════════════════════════════════════════════════════════════════
412
+ # Approval Response
413
+ # ═══════════════════════════════════════════════════════════════════
414
+
415
+ @dataclass
416
+ class ApprovalResponse:
417
+ """HITL approval poll response from `/api/v1/governance/approval`."""
418
+
419
+ verdict: Verdict
420
+ reason: str | None = None
421
+ approval_expiration_time: str | None = None
422
+ expired: bool = False
423
+
424
+
425
+ def parse_approval_response(data: dict[str, Any]) -> ApprovalResponse:
426
+ """Parse a raw dict from the approval endpoint into an `ApprovalResponse`."""
427
+ return ApprovalResponse(
428
+ verdict=Verdict.from_string(data.get("verdict") or data.get("action")),
429
+ reason=data.get("reason"),
430
+ approval_expiration_time=data.get("approval_expiration_time"),
431
+ expired=bool(data.get("expired", False)),
432
+ )
433
+
434
+
435
+ # ═══════════════════════════════════════════════════════════════════
436
+ # HITL Config
437
+ # ═══════════════════════════════════════════════════════════════════
438
+
439
+ @dataclass
440
+ class HITLConfig:
441
+ """Human-in-the-loop polling configuration."""
442
+
443
+ enabled: bool = True
444
+ poll_interval_ms: int = 5_000
445
+ skip_tool_types: set[str] = field(default_factory=set)
446
+
447
+
448
+ DEFAULT_HITL_CONFIG = HITLConfig()
449
+
450
+
451
+ # ═══════════════════════════════════════════════════════════════════
452
+ # Utilities
453
+ # ═══════════════════════════════════════════════════════════════════
454
+
455
+ def rfc3339_now() -> str:
456
+ """Return the current UTC time as an RFC3339 string."""
457
+ return datetime.now(tz=UTC).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
458
+
459
+
460
+ def safe_serialize(value: Any) -> Any:
461
+ """Safely serialize a value for inclusion in a governance event payload.
462
+
463
+ Handles LangChain message objects and other non-serializable types by
464
+ converting them to their string/dict representation.
465
+ """
466
+ if value is None:
467
+ return None
468
+ if isinstance(value, (str, int, float, bool)):
469
+ return value
470
+ if isinstance(value, dict):
471
+ return {k: safe_serialize(v) for k, v in value.items()}
472
+ if isinstance(value, (list, tuple)):
473
+ return [safe_serialize(v) for v in value]
474
+ # LangChain message objects expose a .dict() or .model_dump() method
475
+ if hasattr(value, "model_dump"):
476
+ try:
477
+ return value.model_dump()
478
+ except Exception:
479
+ pass
480
+ if hasattr(value, "dict"):
481
+ try:
482
+ return value.dict()
483
+ except Exception:
484
+ pass
485
+ return str(value)
@@ -0,0 +1,203 @@
1
+ """OpenBox LangGraph SDK — Verdict enforcement."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import warnings
7
+ from typing import Literal
8
+
9
+ from openbox_langgraph.errors import (
10
+ GovernanceBlockedError,
11
+ GovernanceHaltError,
12
+ GuardrailsValidationError,
13
+ )
14
+ from openbox_langgraph.types import (
15
+ GovernanceVerdictResponse,
16
+ Verdict,
17
+ verdict_requires_approval,
18
+ )
19
+
20
+ # ═══════════════════════════════════════════════════════════════════
21
+ # VerdictContext
22
+ # ═══════════════════════════════════════════════════════════════════
23
+
24
+ VerdictContext = Literal[
25
+ "chain_start",
26
+ "chain_end",
27
+ "tool_start",
28
+ "tool_end",
29
+ "llm_start",
30
+ "llm_end",
31
+ "agent_action",
32
+ "agent_finish",
33
+ "graph_node_start",
34
+ "graph_node_end",
35
+ "graph_root_start",
36
+ "graph_root_end",
37
+ "other",
38
+ ]
39
+
40
+
41
+ def lang_graph_event_to_context(event_type: str, *, is_root: bool = False) -> VerdictContext:
42
+ """Map a LangGraph v2 stream event type to a `VerdictContext`.
43
+
44
+ Args:
45
+ event_type: Raw LangGraph event string (e.g. `on_tool_start`).
46
+ is_root: Whether this event belongs to the outermost graph invocation.
47
+ """
48
+ mapping: dict[str, VerdictContext] = {
49
+ "on_chain_start": "graph_root_start" if is_root else "graph_node_start",
50
+ "on_chain_end": "graph_root_end" if is_root else "graph_node_end",
51
+ "on_chat_model_start": "llm_start",
52
+ "on_chat_model_end": "llm_end",
53
+ "on_tool_start": "tool_start",
54
+ "on_tool_end": "tool_end",
55
+ "on_retriever_start": "tool_start",
56
+ "on_retriever_end": "tool_end",
57
+ }
58
+ return mapping.get(event_type, "other")
59
+
60
+
61
+ def is_hitl_applicable(context: VerdictContext) -> bool:
62
+ """Return True if HITL polling applies to this verdict context.
63
+
64
+ HITL applies to start events (before execution) and tool/LLM end events,
65
+ because Behavior Rules on ActivityCompleted can return REQUIRE_APPROVAL.
66
+ """
67
+ return context in (
68
+ "tool_start",
69
+ "tool_end",
70
+ "llm_start",
71
+ "llm_end",
72
+ "graph_node_start",
73
+ "agent_action",
74
+ )
75
+
76
+
77
+ # ═══════════════════════════════════════════════════════════════════
78
+ # Guardrail reason helpers (mirrors TS SDK)
79
+ # ═══════════════════════════════════════════════════════════════════
80
+
81
+ def _clean_guardrail_reason(reason: str) -> str:
82
+ """Strip ReAct scratchpad contamination from a guardrail reason string.
83
+
84
+ Guardrail services may echo the full prompt/trace including agent scratchpad.
85
+ We only want the human-readable reason header and the quoted offending text.
86
+ """
87
+ # 1) Strip ReAct "Question:" line (includes session context)
88
+ reason = re.sub(r"\n?-\s*Question:\s*\[Session context\][^\n]*\n?", "", reason)
89
+ # 2) Strip agent scratchpad (Thought:, Action:, etc.)
90
+ for marker in ("\n\nThought:", "\n\nThought", "\nThought:", "\nThought"):
91
+ idx = reason.find(marker)
92
+ if idx >= 0:
93
+ return reason[:idx].rstrip()
94
+ return reason.rstrip()
95
+
96
+
97
+ def _get_guardrail_failure_reasons(reasons: list | None) -> list[str]:
98
+ """Return a single cleaned reason string for input guardrail failures, mirroring TS SDK:
99
+ - Take only the first reason (Core already returns a primary one)
100
+ - Clean off any agent scratchpad sections
101
+ """
102
+ first = next((r.reason for r in (reasons or []) if r.reason), None)
103
+ if not first:
104
+ return ["Guardrails validation failed"]
105
+ return [_clean_guardrail_reason(first)]
106
+
107
+
108
+ def _get_guardrail_output_failure_reasons(reasons: list | None) -> list[str]:
109
+ """Return all reason strings for output guardrail failures, mirroring Temporal SDK:
110
+ - Join all reasons with '; ' (output has no ReAct scratchpad contamination)
111
+ """
112
+ strings = [r.reason for r in (reasons or []) if r.reason]
113
+ return ["; ".join(strings)] if strings else ["Guardrails output validation failed"]
114
+
115
+
116
+ # ═══════════════════════════════════════════════════════════════════
117
+ # enforce_verdict
118
+ # ═══════════════════════════════════════════════════════════════════
119
+
120
+ class VerdictEnforcementResult:
121
+ """Result of `enforce_verdict` — indicates what the caller should do next."""
122
+
123
+ def __init__(self, *, requires_hitl: bool = False, blocked: bool = False) -> None:
124
+ self.requires_hitl = requires_hitl
125
+ self.blocked = blocked
126
+
127
+
128
+ _OBSERVATION_ONLY_CONTEXTS: frozenset[VerdictContext] = frozenset({
129
+ "chain_end",
130
+ "graph_root_end",
131
+ # graph_node_end is NOT observation-only: ToolCompleted/LLMCompleted verdicts
132
+ # from Behavior Rules must be enforced, matching Temporal SDK's ActivityCompleted handling.
133
+ "agent_finish",
134
+ "other",
135
+ })
136
+
137
+
138
+ def enforce_verdict(
139
+ response: GovernanceVerdictResponse,
140
+ context: VerdictContext,
141
+ ) -> VerdictEnforcementResult:
142
+ """Enforce the governance verdict by raising an error or signalling HITL.
143
+
144
+ Observation-only contexts (chain/root/node end, agent_finish) are never
145
+ enforced — they are for telemetry only.
146
+
147
+ Args:
148
+ response: The verdict response from OpenBox Core.
149
+ context: The verdict context that determines enforcement behaviour.
150
+
151
+ Returns:
152
+ A `VerdictEnforcementResult` indicating whether HITL should start.
153
+
154
+ Raises:
155
+ GuardrailsValidationError: When guardrails validation has failed.
156
+ GovernanceHaltError: When the verdict is HALT.
157
+ GovernanceBlockedError: When the verdict is BLOCK.
158
+ GovernanceBlockedError: When REQUIRE_APPROVAL arrives at a non-HITL context.
159
+ """
160
+ if context in _OBSERVATION_ONLY_CONTEXTS:
161
+ return VerdictEnforcementResult()
162
+
163
+ verdict = response.verdict
164
+ reason = response.reason
165
+ policy_id = response.policy_id
166
+ risk_score = response.risk_score
167
+
168
+ # 1. HALT — highest priority, mirrors Temporal: checked before guardrails
169
+ if verdict == Verdict.HALT:
170
+ msg = reason or "Workflow halted by governance policy"
171
+ raise GovernanceHaltError(msg, policy_id=policy_id, risk_score=risk_score)
172
+
173
+ # 2. BLOCK — checked before guardrails (Temporal order)
174
+ if verdict == Verdict.BLOCK:
175
+ msg = reason or "Action blocked by governance policy"
176
+ raise GovernanceBlockedError(msg, policy_id, risk_score)
177
+
178
+ # 3. Guardrails validation failure — block when verdict is ALLOW/CONSTRAIN but guardrails failed
179
+ if response.guardrails_result and not response.guardrails_result.validation_passed:
180
+ gr = response.guardrails_result
181
+ if gr.input_type == "activity_output":
182
+ # Output guardrail block: all reasons joined (Temporal pattern, no scratchpad)
183
+ reasons = _get_guardrail_output_failure_reasons(gr.reasons)
184
+ else:
185
+ # Input guardrail block: first reason, scratchpad-cleaned (TS SDK pattern)
186
+ reasons = _get_guardrail_failure_reasons(gr.reasons)
187
+ raise GuardrailsValidationError(reasons)
188
+
189
+ # 4. REQUIRE_APPROVAL
190
+ if verdict_requires_approval(verdict):
191
+ if is_hitl_applicable(context):
192
+ return VerdictEnforcementResult(requires_hitl=True)
193
+ msg = reason or "Action requires approval but cannot be paused at this stage"
194
+ raise GovernanceBlockedError(msg, policy_id, risk_score)
195
+
196
+ # 5. CONSTRAIN — warn and continue
197
+ if verdict == Verdict.CONSTRAIN and reason:
198
+ suffix = f" (policy: {policy_id})" if policy_id else ""
199
+ warnings.warn(f"[OpenBox] Governance constraint: {reason}{suffix}", stacklevel=2)
200
+ return VerdictEnforcementResult()
201
+
202
+ # 6. ALLOW — no action
203
+ return VerdictEnforcementResult()