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.
- openbox_langgraph/__init__.py +130 -0
- openbox_langgraph/client.py +358 -0
- openbox_langgraph/config.py +264 -0
- openbox_langgraph/db_governance_hooks.py +897 -0
- openbox_langgraph/errors.py +114 -0
- openbox_langgraph/file_governance_hooks.py +413 -0
- openbox_langgraph/hitl.py +88 -0
- openbox_langgraph/hook_governance.py +397 -0
- openbox_langgraph/http_governance_hooks.py +695 -0
- openbox_langgraph/langgraph_handler.py +1616 -0
- openbox_langgraph/otel_setup.py +468 -0
- openbox_langgraph/span_processor.py +253 -0
- openbox_langgraph/tracing.py +352 -0
- openbox_langgraph/types.py +485 -0
- openbox_langgraph/verdict_handler.py +203 -0
- openbox_langgraph_sdk_python-0.1.0.dist-info/METADATA +492 -0
- openbox_langgraph_sdk_python-0.1.0.dist-info/RECORD +18 -0
- openbox_langgraph_sdk_python-0.1.0.dist-info/WHEEL +4 -0
|
@@ -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()
|