openbox-langchain-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_langchain/__init__.py +124 -0
- openbox_langchain/middleware.py +239 -0
- openbox_langchain/middleware_factory.py +67 -0
- openbox_langchain/middleware_hook_handlers.py +231 -0
- openbox_langchain/middleware_hooks.py +220 -0
- openbox_langchain/middleware_tool_hook.py +144 -0
- openbox_langchain_sdk_python-0.1.0.dist-info/METADATA +121 -0
- openbox_langchain_sdk_python-0.1.0.dist-info/RECORD +9 -0
- openbox_langchain_sdk_python-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""OpenBox LangChain SDK — governance for LangChain agents via middleware."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
# Re-export the openbox-langgraph-sdk public surface
|
|
6
|
+
from openbox_langgraph import (
|
|
7
|
+
DEFAULT_HITL_CONFIG,
|
|
8
|
+
ApprovalExpiredError,
|
|
9
|
+
ApprovalRejectedError,
|
|
10
|
+
ApprovalResponse,
|
|
11
|
+
ApprovalTimeoutError,
|
|
12
|
+
GovernanceBlockedError,
|
|
13
|
+
GovernanceClient,
|
|
14
|
+
GovernanceConfig,
|
|
15
|
+
GovernanceHaltError,
|
|
16
|
+
GovernanceVerdictResponse,
|
|
17
|
+
GuardrailsReason,
|
|
18
|
+
GuardrailsResult,
|
|
19
|
+
GuardrailsValidationError,
|
|
20
|
+
HITLConfig,
|
|
21
|
+
LangChainGovernanceEvent,
|
|
22
|
+
OpenBoxAuthError,
|
|
23
|
+
OpenBoxError,
|
|
24
|
+
OpenBoxInsecureURLError,
|
|
25
|
+
OpenBoxNetworkError,
|
|
26
|
+
Verdict,
|
|
27
|
+
VerdictContext,
|
|
28
|
+
WorkflowEventType,
|
|
29
|
+
WorkflowSpanBuffer,
|
|
30
|
+
WorkflowSpanProcessor,
|
|
31
|
+
build_auth_headers,
|
|
32
|
+
create_span,
|
|
33
|
+
enforce_verdict,
|
|
34
|
+
get_global_config,
|
|
35
|
+
highest_priority_verdict,
|
|
36
|
+
initialize,
|
|
37
|
+
is_hitl_applicable,
|
|
38
|
+
lang_graph_event_to_context,
|
|
39
|
+
merge_config,
|
|
40
|
+
parse_approval_response,
|
|
41
|
+
parse_governance_response,
|
|
42
|
+
poll_until_decision,
|
|
43
|
+
rfc3339_now,
|
|
44
|
+
safe_serialize,
|
|
45
|
+
setup_opentelemetry_for_governance,
|
|
46
|
+
to_server_event_type,
|
|
47
|
+
traced,
|
|
48
|
+
verdict_from_string,
|
|
49
|
+
verdict_priority,
|
|
50
|
+
verdict_requires_approval,
|
|
51
|
+
verdict_should_stop,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
from openbox_langchain.middleware import (
|
|
55
|
+
OpenBoxLangChainMiddleware,
|
|
56
|
+
OpenBoxLangChainMiddlewareOptions,
|
|
57
|
+
)
|
|
58
|
+
from openbox_langchain.middleware_factory import create_openbox_langchain_middleware
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
__version__ = version("openbox-langchain-sdk-python")
|
|
62
|
+
except PackageNotFoundError:
|
|
63
|
+
__version__ = "unknown"
|
|
64
|
+
|
|
65
|
+
__all__ = [
|
|
66
|
+
# Types
|
|
67
|
+
"DEFAULT_HITL_CONFIG",
|
|
68
|
+
# Errors
|
|
69
|
+
"ApprovalExpiredError",
|
|
70
|
+
"ApprovalRejectedError",
|
|
71
|
+
"ApprovalResponse",
|
|
72
|
+
"ApprovalTimeoutError",
|
|
73
|
+
"GovernanceBlockedError",
|
|
74
|
+
# Client
|
|
75
|
+
"GovernanceClient",
|
|
76
|
+
# Config
|
|
77
|
+
"GovernanceConfig",
|
|
78
|
+
"GovernanceHaltError",
|
|
79
|
+
"GovernanceVerdictResponse",
|
|
80
|
+
"GuardrailsReason",
|
|
81
|
+
"GuardrailsResult",
|
|
82
|
+
"GuardrailsValidationError",
|
|
83
|
+
"HITLConfig",
|
|
84
|
+
"LangChainGovernanceEvent",
|
|
85
|
+
"OpenBoxAuthError",
|
|
86
|
+
"OpenBoxError",
|
|
87
|
+
"OpenBoxInsecureURLError",
|
|
88
|
+
# Middleware
|
|
89
|
+
"OpenBoxLangChainMiddleware",
|
|
90
|
+
"OpenBoxLangChainMiddlewareOptions",
|
|
91
|
+
"OpenBoxNetworkError",
|
|
92
|
+
"Verdict",
|
|
93
|
+
# Verdict
|
|
94
|
+
"VerdictContext",
|
|
95
|
+
"WorkflowEventType",
|
|
96
|
+
"WorkflowSpanBuffer",
|
|
97
|
+
# OTel
|
|
98
|
+
"WorkflowSpanProcessor",
|
|
99
|
+
# Version
|
|
100
|
+
"__version__",
|
|
101
|
+
"build_auth_headers",
|
|
102
|
+
# Primary API
|
|
103
|
+
"create_openbox_langchain_middleware",
|
|
104
|
+
"create_span",
|
|
105
|
+
"enforce_verdict",
|
|
106
|
+
"get_global_config",
|
|
107
|
+
"highest_priority_verdict",
|
|
108
|
+
"initialize",
|
|
109
|
+
"is_hitl_applicable",
|
|
110
|
+
"lang_graph_event_to_context",
|
|
111
|
+
"merge_config",
|
|
112
|
+
"parse_approval_response",
|
|
113
|
+
"parse_governance_response",
|
|
114
|
+
"poll_until_decision",
|
|
115
|
+
"rfc3339_now",
|
|
116
|
+
"safe_serialize",
|
|
117
|
+
"setup_opentelemetry_for_governance",
|
|
118
|
+
"to_server_event_type",
|
|
119
|
+
"traced",
|
|
120
|
+
"verdict_from_string",
|
|
121
|
+
"verdict_priority",
|
|
122
|
+
"verdict_requires_approval",
|
|
123
|
+
"verdict_should_stop",
|
|
124
|
+
]
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""OpenBox governance middleware for LangChain agents.
|
|
2
|
+
|
|
3
|
+
Subclasses AgentMiddleware to intercept agent lifecycle and enforce governance:
|
|
4
|
+
before_agent → WorkflowStarted + SignalReceived + pre-screen guardrails
|
|
5
|
+
wrap_model_call → LLMStarted (PII redaction) → Model → LLMCompleted
|
|
6
|
+
wrap_tool_call → ToolStarted → Tool (OTel spans) → ToolCompleted
|
|
7
|
+
after_agent → WorkflowCompleted + cleanup
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
from openbox_langchain import create_openbox_langchain_middleware
|
|
11
|
+
middleware = create_openbox_langchain_middleware(api_url=..., api_key=...)
|
|
12
|
+
agent = create_agent(model=..., tools=[...], middleware=[middleware])
|
|
13
|
+
result = agent.invoke({"messages": [("user", "Hello")]})
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import concurrent.futures
|
|
20
|
+
import logging
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from typing import TYPE_CHECKING, Any
|
|
23
|
+
|
|
24
|
+
from langchain.agents.middleware.types import AgentMiddleware, ModelRequest
|
|
25
|
+
from langgraph.prebuilt.tool_node import ToolCallRequest
|
|
26
|
+
from openbox_langgraph.client import GovernanceClient
|
|
27
|
+
from openbox_langgraph.config import GovernanceConfig, get_global_config, merge_config
|
|
28
|
+
from openbox_langgraph.types import GovernanceVerdictResponse
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from openbox_langgraph.span_processor import WorkflowSpanProcessor
|
|
32
|
+
|
|
33
|
+
_logger = logging.getLogger("openbox_langchain")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class OpenBoxLangChainMiddlewareOptions:
|
|
38
|
+
"""Configuration for OpenBoxLangChainMiddleware."""
|
|
39
|
+
|
|
40
|
+
agent_name: str | None = None
|
|
41
|
+
session_id: str | None = None
|
|
42
|
+
task_queue: str = "langchain"
|
|
43
|
+
on_api_error: str = "fail_open"
|
|
44
|
+
governance_timeout: float = 30.0
|
|
45
|
+
tool_type_map: dict[str, str] = field(default_factory=dict)
|
|
46
|
+
skip_tool_types: set[str] = field(default_factory=set)
|
|
47
|
+
sqlalchemy_engine: Any = None
|
|
48
|
+
send_chain_start_event: bool = True
|
|
49
|
+
send_chain_end_event: bool = True
|
|
50
|
+
send_llm_start_event: bool = True
|
|
51
|
+
send_llm_end_event: bool = True
|
|
52
|
+
send_tool_start_event: bool = True
|
|
53
|
+
send_tool_end_event: bool = True
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class OpenBoxLangChainMiddleware(AgentMiddleware):
|
|
57
|
+
"""AgentMiddleware implementing OpenBox governance for LangChain agents.
|
|
58
|
+
|
|
59
|
+
Hooks map directly to the governance event lifecycle:
|
|
60
|
+
- before_agent: session setup (WorkflowStarted, SignalReceived, pre-screen)
|
|
61
|
+
- wrap_model_call: LLM governance (LLMStarted/Completed, PII redaction)
|
|
62
|
+
- wrap_tool_call: tool governance (ToolStarted/Completed, SpanProcessor ctx)
|
|
63
|
+
- after_agent: session close (WorkflowCompleted, cleanup)
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(self, options: OpenBoxLangChainMiddlewareOptions | None = None) -> None:
|
|
67
|
+
opts = options or OpenBoxLangChainMiddlewareOptions()
|
|
68
|
+
self._options = opts
|
|
69
|
+
|
|
70
|
+
self._config: GovernanceConfig = merge_config({
|
|
71
|
+
"on_api_error": opts.on_api_error,
|
|
72
|
+
"api_timeout": opts.governance_timeout,
|
|
73
|
+
"send_chain_start_event": opts.send_chain_start_event,
|
|
74
|
+
"send_chain_end_event": opts.send_chain_end_event,
|
|
75
|
+
"send_tool_start_event": opts.send_tool_start_event,
|
|
76
|
+
"send_tool_end_event": opts.send_tool_end_event,
|
|
77
|
+
"send_llm_start_event": opts.send_llm_start_event,
|
|
78
|
+
"send_llm_end_event": opts.send_llm_end_event,
|
|
79
|
+
"skip_tool_types": opts.skip_tool_types,
|
|
80
|
+
"session_id": opts.session_id,
|
|
81
|
+
"agent_name": opts.agent_name,
|
|
82
|
+
"task_queue": opts.task_queue,
|
|
83
|
+
"tool_type_map": opts.tool_type_map,
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
gc = get_global_config()
|
|
87
|
+
self._client = GovernanceClient(
|
|
88
|
+
api_url=gc.api_url,
|
|
89
|
+
api_key=gc.api_key,
|
|
90
|
+
timeout=gc.governance_timeout,
|
|
91
|
+
on_api_error=self._config.on_api_error,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# OTel span processor for hook-level governance (Layer 2/3)
|
|
95
|
+
self._span_processor: WorkflowSpanProcessor | None = None
|
|
96
|
+
if gc.api_url and gc.api_key:
|
|
97
|
+
try:
|
|
98
|
+
from openbox_langgraph.otel_setup import setup_opentelemetry_for_governance
|
|
99
|
+
from openbox_langgraph.span_processor import WorkflowSpanProcessor as WSP
|
|
100
|
+
|
|
101
|
+
self._span_processor = WSP()
|
|
102
|
+
setup_opentelemetry_for_governance(
|
|
103
|
+
span_processor=self._span_processor,
|
|
104
|
+
api_url=gc.api_url,
|
|
105
|
+
api_key=gc.api_key,
|
|
106
|
+
ignored_urls=[gc.api_url],
|
|
107
|
+
api_timeout=gc.governance_timeout,
|
|
108
|
+
on_api_error=self._config.on_api_error,
|
|
109
|
+
sqlalchemy_engine=opts.sqlalchemy_engine,
|
|
110
|
+
)
|
|
111
|
+
logging.getLogger("opentelemetry.context").setLevel(logging.CRITICAL)
|
|
112
|
+
except Exception:
|
|
113
|
+
_logger.warning("Failed to initialize OTel hooks; Layer 2/3 disabled")
|
|
114
|
+
|
|
115
|
+
# Reusable thread pool for sync-to-async bridge (shut down via close())
|
|
116
|
+
self._sync_executor: concurrent.futures.ThreadPoolExecutor | None = None
|
|
117
|
+
|
|
118
|
+
# Per-invocation state (reset in before_agent)
|
|
119
|
+
self._sync_mode: bool = False
|
|
120
|
+
self._workflow_id: str = ""
|
|
121
|
+
self._run_id: str = ""
|
|
122
|
+
self._pre_screen_response: GovernanceVerdictResponse | None = None
|
|
123
|
+
self._first_llm_call: bool = True
|
|
124
|
+
self._workflow_type: str = opts.agent_name or "LangChainRun"
|
|
125
|
+
|
|
126
|
+
def close(self) -> None:
|
|
127
|
+
"""Release resources (thread pool). Safe to call multiple times."""
|
|
128
|
+
if self._sync_executor is not None:
|
|
129
|
+
self._sync_executor.shutdown(wait=False)
|
|
130
|
+
self._sync_executor = None
|
|
131
|
+
|
|
132
|
+
# ─── Async-to-sync bridge ──────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
def _run_async(self, coro):
|
|
135
|
+
"""Run async coroutine from sync context with OTel context propagation."""
|
|
136
|
+
try:
|
|
137
|
+
loop = asyncio.get_running_loop()
|
|
138
|
+
except RuntimeError:
|
|
139
|
+
loop = None
|
|
140
|
+
if loop and loop.is_running():
|
|
141
|
+
from opentelemetry import context as otel_context
|
|
142
|
+
ctx = otel_context.get_current()
|
|
143
|
+
|
|
144
|
+
def _run_with_ctx():
|
|
145
|
+
token = otel_context.attach(ctx)
|
|
146
|
+
try:
|
|
147
|
+
return asyncio.run(coro)
|
|
148
|
+
finally:
|
|
149
|
+
try:
|
|
150
|
+
otel_context.detach(token)
|
|
151
|
+
except Exception:
|
|
152
|
+
pass
|
|
153
|
+
|
|
154
|
+
if self._sync_executor is None:
|
|
155
|
+
self._sync_executor = concurrent.futures.ThreadPoolExecutor(max_workers=1)
|
|
156
|
+
return self._sync_executor.submit(_run_with_ctx).result()
|
|
157
|
+
return asyncio.run(coro)
|
|
158
|
+
|
|
159
|
+
# ─── Sync hooks (for invoke/stream) ────────────────────────────
|
|
160
|
+
|
|
161
|
+
def before_agent(self, state, runtime) -> dict[str, Any] | None:
|
|
162
|
+
self._sync_mode = True
|
|
163
|
+
if self._span_processor:
|
|
164
|
+
self._span_processor.set_sync_mode(True)
|
|
165
|
+
from openbox_langchain.middleware_hook_handlers import handle_before_agent
|
|
166
|
+
return self._run_async(handle_before_agent(self, state, runtime))
|
|
167
|
+
|
|
168
|
+
def after_agent(self, state, runtime) -> dict[str, Any] | None:
|
|
169
|
+
from openbox_langchain.middleware_hook_handlers import handle_after_agent
|
|
170
|
+
self._run_async(handle_after_agent(self, state, runtime))
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
def wrap_model_call(self, request: ModelRequest, handler) -> Any:
|
|
174
|
+
from opentelemetry import context as otel_ctx
|
|
175
|
+
from opentelemetry import trace as otel_tr
|
|
176
|
+
|
|
177
|
+
from openbox_langchain.middleware_hook_handlers import handle_wrap_model_call
|
|
178
|
+
|
|
179
|
+
tracer = otel_tr.get_tracer("openbox-langchain")
|
|
180
|
+
span = tracer.start_span("llm.call.sync", kind=otel_tr.SpanKind.INTERNAL)
|
|
181
|
+
token = otel_ctx.attach(otel_tr.set_span_in_context(span))
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
async def async_handler(req):
|
|
185
|
+
return handler(req)
|
|
186
|
+
|
|
187
|
+
return self._run_async(handle_wrap_model_call(self, request, async_handler))
|
|
188
|
+
finally:
|
|
189
|
+
span.end()
|
|
190
|
+
try:
|
|
191
|
+
otel_ctx.detach(token)
|
|
192
|
+
except Exception:
|
|
193
|
+
pass
|
|
194
|
+
|
|
195
|
+
def wrap_tool_call(self, request: ToolCallRequest, handler) -> Any:
|
|
196
|
+
from opentelemetry import context as otel_ctx
|
|
197
|
+
from opentelemetry import trace as otel_tr
|
|
198
|
+
|
|
199
|
+
from openbox_langchain.middleware_tool_hook import handle_wrap_tool_call
|
|
200
|
+
|
|
201
|
+
tool_name = (
|
|
202
|
+
request.tool_call.get("name", "tool") if hasattr(request, "tool_call") else "tool"
|
|
203
|
+
)
|
|
204
|
+
tracer = otel_tr.get_tracer("openbox-langchain")
|
|
205
|
+
span = tracer.start_span(f"tool.{tool_name}.sync", kind=otel_tr.SpanKind.INTERNAL)
|
|
206
|
+
token = otel_ctx.attach(otel_tr.set_span_in_context(span))
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
async def async_handler(req):
|
|
210
|
+
return handler(req)
|
|
211
|
+
|
|
212
|
+
return self._run_async(handle_wrap_tool_call(self, request, async_handler))
|
|
213
|
+
finally:
|
|
214
|
+
span.end()
|
|
215
|
+
try:
|
|
216
|
+
otel_ctx.detach(token)
|
|
217
|
+
except Exception:
|
|
218
|
+
pass
|
|
219
|
+
|
|
220
|
+
# ─── Async hooks (for ainvoke/astream) ─────────────────────────
|
|
221
|
+
|
|
222
|
+
async def abefore_agent(self, state, runtime) -> dict[str, Any] | None:
|
|
223
|
+
self._sync_mode = False
|
|
224
|
+
if self._span_processor:
|
|
225
|
+
self._span_processor.set_sync_mode(False)
|
|
226
|
+
from openbox_langchain.middleware_hook_handlers import handle_before_agent
|
|
227
|
+
return await handle_before_agent(self, state, runtime)
|
|
228
|
+
|
|
229
|
+
async def aafter_agent(self, state, runtime) -> dict[str, Any] | None:
|
|
230
|
+
from openbox_langchain.middleware_hook_handlers import handle_after_agent
|
|
231
|
+
return await handle_after_agent(self, state, runtime)
|
|
232
|
+
|
|
233
|
+
async def awrap_model_call(self, request: ModelRequest, handler) -> Any:
|
|
234
|
+
from openbox_langchain.middleware_hook_handlers import handle_wrap_model_call
|
|
235
|
+
return await handle_wrap_model_call(self, request, handler)
|
|
236
|
+
|
|
237
|
+
async def awrap_tool_call(self, request: ToolCallRequest, handler) -> Any:
|
|
238
|
+
from openbox_langchain.middleware_tool_hook import handle_wrap_tool_call
|
|
239
|
+
return await handle_wrap_tool_call(self, request, handler)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Factory for creating configured OpenBoxLangChainMiddleware instances.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
from openbox_langchain import create_openbox_langchain_middleware
|
|
5
|
+
middleware = create_openbox_langchain_middleware(
|
|
6
|
+
api_url=os.environ["OPENBOX_URL"],
|
|
7
|
+
api_key=os.environ["OPENBOX_API_KEY"],
|
|
8
|
+
agent_name="MyAgent",
|
|
9
|
+
)
|
|
10
|
+
agent = create_agent(model=..., tools=[...], middleware=[middleware])
|
|
11
|
+
result = agent.invoke({"messages": [("user", "Hello")]})
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import dataclasses
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from openbox_langchain.middleware import (
|
|
20
|
+
OpenBoxLangChainMiddleware,
|
|
21
|
+
OpenBoxLangChainMiddlewareOptions,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def create_openbox_langchain_middleware(
|
|
26
|
+
*,
|
|
27
|
+
api_url: str,
|
|
28
|
+
api_key: str,
|
|
29
|
+
agent_name: str | None = None,
|
|
30
|
+
governance_timeout: float = 30.0,
|
|
31
|
+
validate: bool = True,
|
|
32
|
+
sqlalchemy_engine: Any = None,
|
|
33
|
+
**kwargs: Any,
|
|
34
|
+
) -> OpenBoxLangChainMiddleware:
|
|
35
|
+
"""Create a configured OpenBoxLangChainMiddleware for create_agent(middleware=[...]).
|
|
36
|
+
|
|
37
|
+
Validates the API key and sets up global config before returning the middleware.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
api_url: Base URL of your OpenBox Core instance.
|
|
41
|
+
api_key: API key in ``obx_live_*`` or ``obx_test_*`` format.
|
|
42
|
+
agent_name: Agent name as configured in the dashboard.
|
|
43
|
+
governance_timeout: HTTP timeout in seconds (default 30.0).
|
|
44
|
+
validate: If True, validates the API key against the server on startup.
|
|
45
|
+
sqlalchemy_engine: Optional SQLAlchemy Engine for DB governance.
|
|
46
|
+
**kwargs: Additional kwargs forwarded to OpenBoxLangChainMiddlewareOptions.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
A configured ``OpenBoxLangChainMiddleware`` ready for create_agent().
|
|
50
|
+
"""
|
|
51
|
+
from openbox_langgraph.config import initialize
|
|
52
|
+
|
|
53
|
+
initialize(
|
|
54
|
+
api_url=api_url,
|
|
55
|
+
api_key=api_key,
|
|
56
|
+
governance_timeout=governance_timeout,
|
|
57
|
+
validate=validate,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
valid_fields = {f.name for f in dataclasses.fields(OpenBoxLangChainMiddlewareOptions)}
|
|
61
|
+
options = OpenBoxLangChainMiddlewareOptions(
|
|
62
|
+
agent_name=agent_name,
|
|
63
|
+
governance_timeout=governance_timeout,
|
|
64
|
+
sqlalchemy_engine=sqlalchemy_engine,
|
|
65
|
+
**{k: v for k, v in kwargs.items() if k in valid_fields},
|
|
66
|
+
)
|
|
67
|
+
return OpenBoxLangChainMiddleware(options)
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Governance hook handler functions for OpenBoxLangChainMiddleware.
|
|
2
|
+
|
|
3
|
+
before_agent / after_agent / wrap_model_call / wrap_tool_call implementations.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import time
|
|
10
|
+
import uuid
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
|
|
13
|
+
from openbox_langgraph.types import LangChainGovernanceEvent, safe_serialize
|
|
14
|
+
from openbox_langgraph.verdict_handler import enforce_verdict
|
|
15
|
+
|
|
16
|
+
from openbox_langchain.middleware_hooks import (
|
|
17
|
+
_apply_pii_redaction,
|
|
18
|
+
_base_event_fields,
|
|
19
|
+
_evaluate,
|
|
20
|
+
_extract_governance_blocked,
|
|
21
|
+
_extract_last_user_message,
|
|
22
|
+
_extract_prompt_from_messages,
|
|
23
|
+
_extract_response_metadata,
|
|
24
|
+
_poll_approval_or_halt,
|
|
25
|
+
_run_with_otel_context,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
_logger = logging.getLogger("openbox_langchain")
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from openbox_langchain.middleware import OpenBoxLangChainMiddleware
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
35
|
+
# Hook: before_agent → WorkflowStarted + pre-screen
|
|
36
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def handle_before_agent(
|
|
40
|
+
mw: OpenBoxLangChainMiddleware, state: Any, runtime: Any,
|
|
41
|
+
) -> dict[str, Any] | None:
|
|
42
|
+
"""Session setup: SignalReceived + WorkflowStarted + pre-screen guardrails."""
|
|
43
|
+
# Generate unique session IDs per invocation
|
|
44
|
+
config = getattr(runtime, "config", None) or {}
|
|
45
|
+
configurable = config.get("configurable", {}) if isinstance(config, dict) else {}
|
|
46
|
+
thread_id = configurable.get("thread_id", "langchain")
|
|
47
|
+
_turn = uuid.uuid4().hex
|
|
48
|
+
mw._workflow_id = f"{thread_id}-{_turn[:8]}"
|
|
49
|
+
mw._run_id = f"{thread_id}-run-{_turn[8:16]}"
|
|
50
|
+
mw._first_llm_call = True
|
|
51
|
+
mw._pre_screen_response = None
|
|
52
|
+
|
|
53
|
+
base = _base_event_fields(mw)
|
|
54
|
+
messages = (
|
|
55
|
+
state.get("messages", []) if isinstance(state, dict)
|
|
56
|
+
else getattr(state, "messages", [])
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# SignalReceived — user prompt as trigger
|
|
60
|
+
user_prompt = _extract_last_user_message(messages)
|
|
61
|
+
if user_prompt:
|
|
62
|
+
sig_event = LangChainGovernanceEvent(
|
|
63
|
+
**base, event_type="SignalReceived",
|
|
64
|
+
activity_id=f"{mw._run_id}-sig", activity_type="user_prompt",
|
|
65
|
+
signal_name="user_prompt", signal_args=[user_prompt],
|
|
66
|
+
)
|
|
67
|
+
await _evaluate(mw, sig_event)
|
|
68
|
+
|
|
69
|
+
# WorkflowStarted
|
|
70
|
+
if mw._config.send_chain_start_event:
|
|
71
|
+
wf_event = LangChainGovernanceEvent(
|
|
72
|
+
**base, event_type="WorkflowStarted",
|
|
73
|
+
activity_id=f"{mw._run_id}-wf",
|
|
74
|
+
activity_type=mw._workflow_type,
|
|
75
|
+
activity_input=[safe_serialize(state)],
|
|
76
|
+
)
|
|
77
|
+
await _evaluate(mw, wf_event)
|
|
78
|
+
|
|
79
|
+
# Pre-screen LLMStarted (guardrails on user prompt)
|
|
80
|
+
if mw._config.send_llm_start_event and user_prompt and user_prompt.strip():
|
|
81
|
+
gov = LangChainGovernanceEvent(
|
|
82
|
+
**base, event_type="LLMStarted",
|
|
83
|
+
activity_id=f"{mw._run_id}-pre", activity_type="llm_call",
|
|
84
|
+
activity_input=[{"prompt": user_prompt}], prompt=user_prompt,
|
|
85
|
+
)
|
|
86
|
+
response = await _evaluate(mw, gov)
|
|
87
|
+
|
|
88
|
+
if response is not None:
|
|
89
|
+
enforcement_error: Exception | None = None
|
|
90
|
+
try:
|
|
91
|
+
result = enforce_verdict(response, "llm_start")
|
|
92
|
+
except Exception as exc:
|
|
93
|
+
enforcement_error = exc
|
|
94
|
+
|
|
95
|
+
if enforcement_error is not None and mw._config.send_chain_end_event:
|
|
96
|
+
wf_end = LangChainGovernanceEvent(
|
|
97
|
+
**_base_event_fields(mw), event_type="WorkflowCompleted",
|
|
98
|
+
activity_id=f"{mw._run_id}-wf",
|
|
99
|
+
activity_type=mw._workflow_type,
|
|
100
|
+
status="failed", error=str(enforcement_error),
|
|
101
|
+
)
|
|
102
|
+
await _evaluate(mw, wf_end)
|
|
103
|
+
raise enforcement_error
|
|
104
|
+
|
|
105
|
+
if result and result.requires_hitl:
|
|
106
|
+
await _poll_approval_or_halt(mw, f"{mw._run_id}-pre", "llm_call")
|
|
107
|
+
|
|
108
|
+
mw._pre_screen_response = response
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
113
|
+
# Hook: after_agent → WorkflowCompleted
|
|
114
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
async def handle_after_agent(
|
|
118
|
+
mw: OpenBoxLangChainMiddleware, state: Any, runtime: Any,
|
|
119
|
+
) -> dict[str, Any] | None:
|
|
120
|
+
"""Session close: WorkflowCompleted + cleanup."""
|
|
121
|
+
if mw._config.send_chain_end_event:
|
|
122
|
+
messages = (
|
|
123
|
+
state.get("messages", []) if isinstance(state, dict)
|
|
124
|
+
else getattr(state, "messages", [])
|
|
125
|
+
)
|
|
126
|
+
last_content = None
|
|
127
|
+
if messages:
|
|
128
|
+
last_msg = messages[-1]
|
|
129
|
+
last_content = (
|
|
130
|
+
getattr(last_msg, "content", None) if hasattr(last_msg, "content")
|
|
131
|
+
else (last_msg.get("content") if isinstance(last_msg, dict) else None)
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
wf_event = LangChainGovernanceEvent(
|
|
135
|
+
**_base_event_fields(mw), event_type="WorkflowCompleted",
|
|
136
|
+
activity_id=f"{mw._run_id}-wf",
|
|
137
|
+
activity_type=mw._workflow_type,
|
|
138
|
+
workflow_output=safe_serialize({"result": last_content}),
|
|
139
|
+
status="completed",
|
|
140
|
+
)
|
|
141
|
+
await _evaluate(mw, wf_event)
|
|
142
|
+
|
|
143
|
+
if mw._span_processor:
|
|
144
|
+
mw._span_processor.unregister_workflow(mw._workflow_id)
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
149
|
+
# Hook: wrap_model_call → LLMStarted/Completed
|
|
150
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
async def handle_wrap_model_call(
|
|
154
|
+
mw: OpenBoxLangChainMiddleware, request: Any, handler: Any,
|
|
155
|
+
) -> Any:
|
|
156
|
+
"""LLM governance: LLMStarted → PII redaction → Model → LLMCompleted."""
|
|
157
|
+
prompt_text = _extract_prompt_from_messages(request.messages)
|
|
158
|
+
if not prompt_text.strip():
|
|
159
|
+
return await handler(request)
|
|
160
|
+
|
|
161
|
+
base = _base_event_fields(mw)
|
|
162
|
+
activity_id = str(uuid.uuid4())
|
|
163
|
+
|
|
164
|
+
# Reuse pre-screen response for first LLM call
|
|
165
|
+
if mw._first_llm_call and mw._pre_screen_response is not None:
|
|
166
|
+
response = mw._pre_screen_response
|
|
167
|
+
mw._pre_screen_response = None
|
|
168
|
+
mw._first_llm_call = False
|
|
169
|
+
activity_id = f"{mw._run_id}-pre"
|
|
170
|
+
else:
|
|
171
|
+
mw._first_llm_call = False
|
|
172
|
+
if mw._config.send_llm_start_event:
|
|
173
|
+
gov = LangChainGovernanceEvent(
|
|
174
|
+
**base, event_type="LLMStarted", activity_id=activity_id,
|
|
175
|
+
activity_type="llm_call", activity_input=[{"prompt": prompt_text}],
|
|
176
|
+
prompt=prompt_text,
|
|
177
|
+
)
|
|
178
|
+
response = await _evaluate(mw, gov)
|
|
179
|
+
else:
|
|
180
|
+
response = None
|
|
181
|
+
|
|
182
|
+
# PII redaction
|
|
183
|
+
if response and response.guardrails_result:
|
|
184
|
+
gr = response.guardrails_result
|
|
185
|
+
if gr.input_type == "activity_input" and gr.redacted_input is not None:
|
|
186
|
+
_apply_pii_redaction(request.messages, gr.redacted_input)
|
|
187
|
+
|
|
188
|
+
# Register SpanProcessor context
|
|
189
|
+
if mw._span_processor:
|
|
190
|
+
mw._span_processor.set_activity_context(mw._workflow_id, activity_id, {
|
|
191
|
+
**base, "event_type": "ActivityStarted",
|
|
192
|
+
"activity_id": activity_id, "activity_type": "llm_call",
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
# Execute model with OTel span + HITL retry loop
|
|
196
|
+
start = time.monotonic()
|
|
197
|
+
while True:
|
|
198
|
+
try:
|
|
199
|
+
model_response = await _run_with_otel_context(
|
|
200
|
+
mw, "llm.call", activity_id, handler, request,
|
|
201
|
+
)
|
|
202
|
+
break
|
|
203
|
+
except Exception as exc:
|
|
204
|
+
hook_err = _extract_governance_blocked(exc)
|
|
205
|
+
if hook_err is not None and hook_err.verdict == "require_approval":
|
|
206
|
+
await _poll_approval_or_halt(mw, activity_id, "llm_call")
|
|
207
|
+
else:
|
|
208
|
+
raise
|
|
209
|
+
duration_ms = (time.monotonic() - start) * 1000
|
|
210
|
+
|
|
211
|
+
# LLMCompleted
|
|
212
|
+
if mw._config.send_llm_end_event:
|
|
213
|
+
meta = _extract_response_metadata(model_response)
|
|
214
|
+
completed = LangChainGovernanceEvent(
|
|
215
|
+
**_base_event_fields(mw), event_type="LLMCompleted",
|
|
216
|
+
activity_id=f"{activity_id}-c", activity_type="llm_call",
|
|
217
|
+
status="completed", duration_ms=duration_ms,
|
|
218
|
+
llm_model=meta.get("llm_model"),
|
|
219
|
+
input_tokens=meta.get("input_tokens"),
|
|
220
|
+
output_tokens=meta.get("output_tokens"),
|
|
221
|
+
total_tokens=meta.get("total_tokens"),
|
|
222
|
+
has_tool_calls=meta.get("has_tool_calls"),
|
|
223
|
+
completion=meta.get("completion"),
|
|
224
|
+
)
|
|
225
|
+
resp = await _evaluate(mw, completed)
|
|
226
|
+
if resp is not None:
|
|
227
|
+
enforce_verdict(resp, "llm_end")
|
|
228
|
+
|
|
229
|
+
if mw._span_processor:
|
|
230
|
+
mw._span_processor.clear_activity_context(mw._workflow_id, activity_id)
|
|
231
|
+
return model_response
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Hook implementations for OpenBoxLangChainMiddleware.
|
|
2
|
+
|
|
3
|
+
Each function implements one middleware hook, mapping to governance events:
|
|
4
|
+
- handle_before_agent → SignalReceived + WorkflowStarted + pre-screen LLMStarted
|
|
5
|
+
- handle_after_agent → WorkflowCompleted + cleanup
|
|
6
|
+
- handle_wrap_model_call → LLMStarted (PII redaction) → Model → LLMCompleted
|
|
7
|
+
- handle_wrap_tool_call → ToolStarted → Tool (OTel spans) → ToolCompleted
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
from typing import TYPE_CHECKING, Any
|
|
14
|
+
|
|
15
|
+
from openbox_langgraph.errors import (
|
|
16
|
+
ApprovalExpiredError,
|
|
17
|
+
ApprovalRejectedError,
|
|
18
|
+
GovernanceBlockedError,
|
|
19
|
+
GovernanceHaltError,
|
|
20
|
+
)
|
|
21
|
+
from openbox_langgraph.hitl import HITLPollParams, poll_until_decision
|
|
22
|
+
from openbox_langgraph.types import (
|
|
23
|
+
rfc3339_now,
|
|
24
|
+
)
|
|
25
|
+
from opentelemetry import context as otel_context
|
|
26
|
+
from opentelemetry import trace as otel_trace
|
|
27
|
+
|
|
28
|
+
_tracer = otel_trace.get_tracer("openbox-langchain")
|
|
29
|
+
_logger = logging.getLogger("openbox_langchain")
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from openbox_langchain.middleware import OpenBoxLangChainMiddleware
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
36
|
+
# Helpers
|
|
37
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _base_event_fields(mw: OpenBoxLangChainMiddleware) -> dict[str, Any]:
|
|
41
|
+
"""Common fields for all governance events."""
|
|
42
|
+
return {
|
|
43
|
+
"source": "workflow-telemetry",
|
|
44
|
+
"workflow_id": mw._workflow_id,
|
|
45
|
+
"run_id": mw._run_id,
|
|
46
|
+
"workflow_type": mw._workflow_type,
|
|
47
|
+
"task_queue": mw._config.task_queue,
|
|
48
|
+
"timestamp": rfc3339_now(),
|
|
49
|
+
"session_id": mw._config.session_id,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
async def _evaluate(mw: OpenBoxLangChainMiddleware, event: Any) -> Any:
|
|
54
|
+
"""Send governance event — sync httpx in sync mode, async otherwise."""
|
|
55
|
+
if mw._sync_mode:
|
|
56
|
+
return mw._client.evaluate_event_sync(event)
|
|
57
|
+
return await mw._client.evaluate_event(event)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _extract_governance_blocked(exc: Exception) -> GovernanceBlockedError | None:
|
|
61
|
+
"""Unwrap GovernanceBlockedError from LLM SDK exception chains."""
|
|
62
|
+
cause: BaseException | None = exc
|
|
63
|
+
seen: set[int] = set()
|
|
64
|
+
while cause is not None:
|
|
65
|
+
if id(cause) in seen:
|
|
66
|
+
break
|
|
67
|
+
seen.add(id(cause))
|
|
68
|
+
if isinstance(cause, GovernanceBlockedError):
|
|
69
|
+
return cause
|
|
70
|
+
cause = getattr(cause, "__cause__", None) or getattr(cause, "__context__", None)
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def _poll_approval_or_halt(
|
|
75
|
+
mw: OpenBoxLangChainMiddleware, activity_id: str, activity_type: str,
|
|
76
|
+
) -> None:
|
|
77
|
+
"""Poll for HITL approval. On rejection/expiry, raises GovernanceHaltError."""
|
|
78
|
+
if mw._span_processor:
|
|
79
|
+
mw._span_processor.clear_activity_abort(mw._workflow_id, activity_id)
|
|
80
|
+
try:
|
|
81
|
+
await poll_until_decision(
|
|
82
|
+
mw._client,
|
|
83
|
+
HITLPollParams(
|
|
84
|
+
workflow_id=mw._workflow_id, run_id=mw._run_id,
|
|
85
|
+
activity_id=activity_id, activity_type=activity_type,
|
|
86
|
+
),
|
|
87
|
+
mw._config.hitl,
|
|
88
|
+
)
|
|
89
|
+
except (ApprovalRejectedError, ApprovalExpiredError) as e:
|
|
90
|
+
if mw._span_processor:
|
|
91
|
+
mw._span_processor.clear_activity_context(mw._workflow_id, activity_id)
|
|
92
|
+
raise GovernanceHaltError(str(e)) from e
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _extract_last_user_message(messages: list[Any]) -> str | None:
|
|
96
|
+
"""Extract last human/user message text from agent state messages."""
|
|
97
|
+
for msg in reversed(messages):
|
|
98
|
+
if isinstance(msg, dict):
|
|
99
|
+
if msg.get("role") in ("user", "human"):
|
|
100
|
+
content = msg.get("content")
|
|
101
|
+
return content if isinstance(content, str) else None
|
|
102
|
+
elif hasattr(msg, "type") and msg.type in ("human", "generic"):
|
|
103
|
+
content = msg.content
|
|
104
|
+
return content if isinstance(content, str) else None
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _extract_prompt_from_messages(messages: Any) -> str:
|
|
109
|
+
"""Extract human/user message text from a messages list."""
|
|
110
|
+
if not isinstance(messages, (list, tuple)):
|
|
111
|
+
return ""
|
|
112
|
+
parts: list[str] = []
|
|
113
|
+
for msg in messages:
|
|
114
|
+
if isinstance(msg, (list, tuple)):
|
|
115
|
+
for inner in msg:
|
|
116
|
+
_append_human_content(inner, parts)
|
|
117
|
+
else:
|
|
118
|
+
_append_human_content(msg, parts)
|
|
119
|
+
return "\n".join(parts)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _append_human_content(msg: Any, parts: list[str]) -> None:
|
|
123
|
+
"""Append human message content to parts list."""
|
|
124
|
+
role = None
|
|
125
|
+
content = None
|
|
126
|
+
if hasattr(msg, "type"):
|
|
127
|
+
role = msg.type
|
|
128
|
+
content = msg.content
|
|
129
|
+
elif isinstance(msg, dict):
|
|
130
|
+
role = msg.get("role") or msg.get("type", "")
|
|
131
|
+
content = msg.get("content", "")
|
|
132
|
+
if role not in ("human", "user", "generic"):
|
|
133
|
+
return
|
|
134
|
+
if isinstance(content, str):
|
|
135
|
+
parts.append(content)
|
|
136
|
+
elif isinstance(content, list):
|
|
137
|
+
for part in content:
|
|
138
|
+
if isinstance(part, dict) and part.get("type") == "text":
|
|
139
|
+
parts.append(part.get("text", ""))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _apply_pii_redaction(messages: list[Any], redacted_input: Any) -> None:
|
|
143
|
+
"""Apply PII redaction to messages in-place from guardrails response."""
|
|
144
|
+
redacted_text = None
|
|
145
|
+
if isinstance(redacted_input, list) and redacted_input:
|
|
146
|
+
first = redacted_input[0]
|
|
147
|
+
if isinstance(first, dict):
|
|
148
|
+
redacted_text = first.get("prompt")
|
|
149
|
+
elif isinstance(first, str):
|
|
150
|
+
redacted_text = first
|
|
151
|
+
elif isinstance(redacted_input, str):
|
|
152
|
+
redacted_text = redacted_input
|
|
153
|
+
|
|
154
|
+
if not redacted_text:
|
|
155
|
+
return
|
|
156
|
+
|
|
157
|
+
for i in range(len(messages) - 1, -1, -1):
|
|
158
|
+
msg = messages[i]
|
|
159
|
+
if hasattr(msg, "type") and msg.type in ("human", "generic"):
|
|
160
|
+
msg.content = redacted_text
|
|
161
|
+
break
|
|
162
|
+
elif isinstance(msg, dict) and msg.get("role") in ("user", "human"):
|
|
163
|
+
msg["content"] = redacted_text
|
|
164
|
+
break
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _extract_response_metadata(response: Any) -> dict[str, Any]:
|
|
168
|
+
"""Extract tokens, model name, completion from model response."""
|
|
169
|
+
result: dict[str, Any] = {}
|
|
170
|
+
ai_msg = response
|
|
171
|
+
if hasattr(response, "message"):
|
|
172
|
+
ai_msg = response.message
|
|
173
|
+
|
|
174
|
+
if hasattr(ai_msg, "response_metadata"):
|
|
175
|
+
meta = ai_msg.response_metadata or {}
|
|
176
|
+
result["llm_model"] = meta.get("model_name") or meta.get("model")
|
|
177
|
+
|
|
178
|
+
usage = getattr(ai_msg, "usage_metadata", None) or {}
|
|
179
|
+
if isinstance(usage, dict):
|
|
180
|
+
result["input_tokens"] = usage.get("input_tokens") or usage.get("prompt_tokens")
|
|
181
|
+
result["output_tokens"] = usage.get("output_tokens") or usage.get("completion_tokens")
|
|
182
|
+
inp = result.get("input_tokens") or 0
|
|
183
|
+
out = result.get("output_tokens") or 0
|
|
184
|
+
result["total_tokens"] = inp + out if (inp or out) else None
|
|
185
|
+
|
|
186
|
+
content = getattr(ai_msg, "content", None)
|
|
187
|
+
if isinstance(content, str):
|
|
188
|
+
result["completion"] = content
|
|
189
|
+
elif isinstance(content, list):
|
|
190
|
+
text_parts = [
|
|
191
|
+
p.get("text", "") for p in content
|
|
192
|
+
if isinstance(p, dict) and p.get("type") == "text"
|
|
193
|
+
]
|
|
194
|
+
result["completion"] = " ".join(text_parts) if text_parts else None
|
|
195
|
+
|
|
196
|
+
result["has_tool_calls"] = bool(getattr(ai_msg, "tool_calls", None))
|
|
197
|
+
return result
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
async def _run_with_otel_context(
|
|
201
|
+
mw: OpenBoxLangChainMiddleware, span_name: str, activity_id: str,
|
|
202
|
+
handler: Any, request: Any,
|
|
203
|
+
) -> Any:
|
|
204
|
+
"""Execute handler inside an OTel span for trace context propagation."""
|
|
205
|
+
parent_ctx = otel_context.get_current()
|
|
206
|
+
span = _tracer.start_span(span_name, context=parent_ctx, kind=otel_trace.SpanKind.INTERNAL)
|
|
207
|
+
token = otel_context.attach(otel_trace.set_span_in_context(span, parent_ctx))
|
|
208
|
+
|
|
209
|
+
trace_id = span.get_span_context().trace_id
|
|
210
|
+
if mw._span_processor and trace_id:
|
|
211
|
+
mw._span_processor.register_trace(trace_id, mw._workflow_id, activity_id)
|
|
212
|
+
|
|
213
|
+
try:
|
|
214
|
+
return await handler(request)
|
|
215
|
+
finally:
|
|
216
|
+
span.end()
|
|
217
|
+
try:
|
|
218
|
+
otel_context.detach(token)
|
|
219
|
+
except Exception:
|
|
220
|
+
pass
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Tool governance hook for OpenBoxLangChainMiddleware.
|
|
2
|
+
|
|
3
|
+
Separated from middleware_hook_handlers.py to stay under 200 lines per file.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import time
|
|
10
|
+
import uuid
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
|
|
13
|
+
from openbox_langgraph.errors import GovernanceBlockedError, GovernanceHaltError
|
|
14
|
+
from openbox_langgraph.types import LangChainGovernanceEvent, safe_serialize
|
|
15
|
+
from openbox_langgraph.verdict_handler import enforce_verdict
|
|
16
|
+
|
|
17
|
+
from openbox_langchain.middleware_hooks import (
|
|
18
|
+
_base_event_fields,
|
|
19
|
+
_evaluate,
|
|
20
|
+
_extract_governance_blocked,
|
|
21
|
+
_poll_approval_or_halt,
|
|
22
|
+
_run_with_otel_context,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
_logger = logging.getLogger("openbox_langchain")
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from openbox_langchain.middleware import OpenBoxLangChainMiddleware
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def handle_wrap_tool_call(
|
|
32
|
+
mw: OpenBoxLangChainMiddleware, request: Any, handler: Any,
|
|
33
|
+
) -> Any:
|
|
34
|
+
"""Tool governance: ToolStarted → Tool (OTel spans) → ToolCompleted."""
|
|
35
|
+
tool_name = request.tool_call["name"]
|
|
36
|
+
tool_args = request.tool_call.get("args", {})
|
|
37
|
+
|
|
38
|
+
# Skip governance for excluded tools
|
|
39
|
+
if tool_name in mw._config.skip_tool_types:
|
|
40
|
+
return await handler(request)
|
|
41
|
+
|
|
42
|
+
activity_id = str(uuid.uuid4())
|
|
43
|
+
tool_type = mw._config.tool_type_map.get(tool_name)
|
|
44
|
+
base = _base_event_fields(mw)
|
|
45
|
+
|
|
46
|
+
# Register SpanProcessor context for Layer 2 hooks
|
|
47
|
+
if mw._span_processor:
|
|
48
|
+
mw._span_processor.set_activity_context(mw._workflow_id, activity_id, {
|
|
49
|
+
**base, "event_type": "ActivityStarted",
|
|
50
|
+
"activity_id": activity_id, "activity_type": tool_name,
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
# ToolStarted + verdict enforcement
|
|
54
|
+
if mw._config.send_tool_start_event:
|
|
55
|
+
gov = LangChainGovernanceEvent(
|
|
56
|
+
**base, event_type="ToolStarted", activity_id=activity_id,
|
|
57
|
+
activity_type=tool_name, activity_input=[safe_serialize(tool_args)],
|
|
58
|
+
tool_name=tool_name, tool_type=tool_type,
|
|
59
|
+
)
|
|
60
|
+
response = await _evaluate(mw, gov)
|
|
61
|
+
if response is not None:
|
|
62
|
+
result = enforce_verdict(response, "tool_start")
|
|
63
|
+
if result.requires_hitl:
|
|
64
|
+
try:
|
|
65
|
+
await _poll_approval_or_halt(mw, activity_id, tool_name)
|
|
66
|
+
except GovernanceHaltError:
|
|
67
|
+
if mw._span_processor:
|
|
68
|
+
mw._span_processor.clear_activity_context(
|
|
69
|
+
mw._workflow_id, activity_id
|
|
70
|
+
)
|
|
71
|
+
raise
|
|
72
|
+
|
|
73
|
+
# Execute tool with OTel span + HITL retry loop
|
|
74
|
+
start = time.monotonic()
|
|
75
|
+
while True:
|
|
76
|
+
try:
|
|
77
|
+
tool_result = await _run_with_otel_context(
|
|
78
|
+
mw, f"tool.{tool_name}", activity_id, handler, request,
|
|
79
|
+
)
|
|
80
|
+
break
|
|
81
|
+
except GovernanceBlockedError as hook_err:
|
|
82
|
+
if hook_err.verdict != "require_approval":
|
|
83
|
+
duration_ms = (time.monotonic() - start) * 1000
|
|
84
|
+
await _send_tool_failed(
|
|
85
|
+
mw, activity_id, tool_name, tool_type, hook_err, duration_ms,
|
|
86
|
+
)
|
|
87
|
+
raise
|
|
88
|
+
await _poll_approval_or_halt(mw, activity_id, tool_name)
|
|
89
|
+
except Exception as exc:
|
|
90
|
+
hook_err = _extract_governance_blocked(exc)
|
|
91
|
+
if hook_err is not None and hook_err.verdict == "require_approval":
|
|
92
|
+
await _poll_approval_or_halt(mw, activity_id, tool_name)
|
|
93
|
+
else:
|
|
94
|
+
duration_ms = (time.monotonic() - start) * 1000
|
|
95
|
+
await _send_tool_failed(mw, activity_id, tool_name, tool_type, exc, duration_ms)
|
|
96
|
+
raise
|
|
97
|
+
duration_ms = (time.monotonic() - start) * 1000
|
|
98
|
+
|
|
99
|
+
# Clear SpanProcessor context
|
|
100
|
+
if mw._span_processor:
|
|
101
|
+
mw._span_processor.clear_activity_context(mw._workflow_id, activity_id)
|
|
102
|
+
|
|
103
|
+
# ToolCompleted + verdict enforcement
|
|
104
|
+
if mw._config.send_tool_end_event:
|
|
105
|
+
try:
|
|
106
|
+
serialized_output = (
|
|
107
|
+
safe_serialize({"result": tool_result})
|
|
108
|
+
if isinstance(tool_result, str)
|
|
109
|
+
else safe_serialize(tool_result)
|
|
110
|
+
)
|
|
111
|
+
except Exception:
|
|
112
|
+
serialized_output = {"result": str(tool_result)}
|
|
113
|
+
completed = LangChainGovernanceEvent(
|
|
114
|
+
**_base_event_fields(mw), event_type="ToolCompleted",
|
|
115
|
+
activity_id=f"{activity_id}-c", activity_type=tool_name,
|
|
116
|
+
activity_output=serialized_output, tool_name=tool_name,
|
|
117
|
+
tool_type=tool_type, status="completed", duration_ms=duration_ms,
|
|
118
|
+
)
|
|
119
|
+
resp = await _evaluate(mw, completed)
|
|
120
|
+
if resp is not None:
|
|
121
|
+
result = enforce_verdict(resp, "tool_end")
|
|
122
|
+
if result.requires_hitl:
|
|
123
|
+
await _poll_approval_or_halt(mw, f"{activity_id}-c", tool_name)
|
|
124
|
+
|
|
125
|
+
return tool_result
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
async def _send_tool_failed(
|
|
129
|
+
mw: OpenBoxLangChainMiddleware,
|
|
130
|
+
activity_id: str, tool_name: str, tool_type: str | None,
|
|
131
|
+
error: Exception, duration_ms: float,
|
|
132
|
+
) -> None:
|
|
133
|
+
"""Send ToolCompleted with failed status."""
|
|
134
|
+
if mw._span_processor:
|
|
135
|
+
mw._span_processor.clear_activity_context(mw._workflow_id, activity_id)
|
|
136
|
+
if mw._config.send_tool_end_event:
|
|
137
|
+
failed_event = LangChainGovernanceEvent(
|
|
138
|
+
**_base_event_fields(mw), event_type="ToolCompleted",
|
|
139
|
+
activity_id=f"{activity_id}-c", activity_type=tool_name,
|
|
140
|
+
activity_output=safe_serialize({"error": str(error)}),
|
|
141
|
+
tool_name=tool_name, tool_type=tool_type,
|
|
142
|
+
status="failed", duration_ms=duration_ms,
|
|
143
|
+
)
|
|
144
|
+
await _evaluate(mw, failed_event)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: openbox-langchain-sdk-python
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: OpenBox governance and observability SDK for LangChain
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Requires-Dist: langchain-core>=0.3.0
|
|
8
|
+
Requires-Dist: langchain>=0.3.0
|
|
9
|
+
Requires-Dist: langgraph>=0.2.0
|
|
10
|
+
Requires-Dist: openbox-langgraph-sdk-python>=0.1.0
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: mypy>=1.10.0; extra == 'dev'
|
|
13
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
|
|
14
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
15
|
+
Requires-Dist: ruff>=0.6.0; extra == 'dev'
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# OpenBox LangChain SDK — Python
|
|
19
|
+
|
|
20
|
+
Governance and observability SDK for LangChain agents. Intercepts agent execution via `AgentMiddleware` to enforce OpenBox policies, guardrails, HITL approval flows, and hook-level governance (HTTP/DB/File I/O).
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install openbox-langchain-sdk-python
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Quick Start
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from langchain.agents import create_agent
|
|
32
|
+
from openbox_langchain import create_openbox_langchain_middleware
|
|
33
|
+
|
|
34
|
+
# 1. Create middleware
|
|
35
|
+
middleware = create_openbox_langchain_middleware(
|
|
36
|
+
api_url="https://core.openbox.ai",
|
|
37
|
+
api_key="obx_live_...",
|
|
38
|
+
agent_name="MyAgent",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# 2. Create agent with middleware
|
|
42
|
+
agent = create_agent(
|
|
43
|
+
model="openai:gpt-4o",
|
|
44
|
+
tools=[...],
|
|
45
|
+
middleware=[middleware],
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# 3. Invoke — governance applied automatically
|
|
49
|
+
result = agent.invoke({"messages": [("user", "your query")]})
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## How It Works
|
|
53
|
+
|
|
54
|
+
Three-layer governance architecture:
|
|
55
|
+
|
|
56
|
+
| Layer | Mechanism | Governs |
|
|
57
|
+
|-------|-----------|---------|
|
|
58
|
+
| 1 | AgentMiddleware hooks | Agent lifecycle (before/after), model calls, tool execution |
|
|
59
|
+
| 2 | Hook Governance | HTTP requests, DB queries, file I/O at kernel boundary |
|
|
60
|
+
| 3 | Activity Context Mapping | Links hook traces to governance activities via OTel |
|
|
61
|
+
|
|
62
|
+
**Middleware hooks:**
|
|
63
|
+
- `before_agent` / `abefore_agent` — Session setup, pre-screen guardrails
|
|
64
|
+
- `wrap_model_call` / `awrap_model_call` — LLM interception, PII redaction
|
|
65
|
+
- `wrap_tool_call` / `awrap_tool_call` — Tool governance, OTel span registration
|
|
66
|
+
- `after_agent` / `aafter_agent` — Session cleanup
|
|
67
|
+
|
|
68
|
+
## Configuration
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
middleware = create_openbox_langchain_middleware(
|
|
72
|
+
api_url="https://core.openbox.ai", # OpenBox Core URL
|
|
73
|
+
api_key="obx_live_...", # API key (obx_live_* or obx_test_*)
|
|
74
|
+
agent_name="MyAgent", # Agent name (from dashboard)
|
|
75
|
+
governance_timeout=30.0, # HTTP timeout in seconds
|
|
76
|
+
validate=True, # Validate API key on startup
|
|
77
|
+
session_id="session-123", # Optional session tracking
|
|
78
|
+
sqlalchemy_engine=engine, # Optional DB governance
|
|
79
|
+
tool_type_map={ # Optional tool classification
|
|
80
|
+
"search_web": "http",
|
|
81
|
+
"query_db": "database",
|
|
82
|
+
},
|
|
83
|
+
)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Supported Agent Types
|
|
87
|
+
|
|
88
|
+
- `create_agent(model, tools, middleware=[...])` — recommended
|
|
89
|
+
- Any LangChain agent builder that accepts `middleware`
|
|
90
|
+
|
|
91
|
+
## Verdict Enforcement
|
|
92
|
+
|
|
93
|
+
5-tier verdict system:
|
|
94
|
+
- **ALLOW** — Request permitted
|
|
95
|
+
- **CONSTRAIN** — Request constrained (e.g., rate limit)
|
|
96
|
+
- **REQUIRE_APPROVAL** — Human approval required (HITL polling)
|
|
97
|
+
- **BLOCK** — Request blocked with error
|
|
98
|
+
- **HALT** — Entire workflow halted (unrecoverable error)
|
|
99
|
+
|
|
100
|
+
## Requirements
|
|
101
|
+
|
|
102
|
+
- Python 3.11+
|
|
103
|
+
- LangChain >= 0.3.0
|
|
104
|
+
- LangGraph >= 0.2.0
|
|
105
|
+
- openbox-langgraph-sdk-python >= 0.1.0
|
|
106
|
+
|
|
107
|
+
## API Reference
|
|
108
|
+
|
|
109
|
+
**Primary factory:**
|
|
110
|
+
- `create_openbox_langchain_middleware()` — Creates configured middleware
|
|
111
|
+
|
|
112
|
+
**Re-exported from langgraph SDK:**
|
|
113
|
+
- `enforce_verdict()` — Enforce verdicts
|
|
114
|
+
- `poll_until_decision()` — HITL approval polling
|
|
115
|
+
- `GovernanceClient`, `GovernanceConfig` — Core types
|
|
116
|
+
|
|
117
|
+
See `openbox_langchain.__init__.py` for full API export list.
|
|
118
|
+
|
|
119
|
+
## License
|
|
120
|
+
|
|
121
|
+
MIT
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
openbox_langchain/__init__.py,sha256=ldOkI33_EGScEOBLuxSFjVac4nPNb5NVCO8MD50QMCI,3090
|
|
2
|
+
openbox_langchain/middleware.py,sha256=PeUtlfaWEQveokqNejDBSjDvnbXcIOtE32RVRa9ishE,10212
|
|
3
|
+
openbox_langchain/middleware_factory.py,sha256=6fl3lDfH9oO9-GMI2t0yQpkd--qnMLh_AQjFI_xkWHY,2302
|
|
4
|
+
openbox_langchain/middleware_hook_handlers.py,sha256=H40_OUGvCIjRE-2YCPa0ZrvU12jm9mKoHdWCG__8mwA,9426
|
|
5
|
+
openbox_langchain/middleware_hooks.py,sha256=k31ag4QMQnxgukwR3XX_VJP9HUpWcBQk-k_1bafemlI,8261
|
|
6
|
+
openbox_langchain/middleware_tool_hook.py,sha256=_qa5oICzHLoi8vCgIZmV3ZkIAlMM3Ta7NR7j1eEIz_U,5587
|
|
7
|
+
openbox_langchain_sdk_python-0.1.0.dist-info/METADATA,sha256=g3fLEL4TvkvcIIBDGmbLIw7Y9J2sKgitQmVz3E1ouOw,3799
|
|
8
|
+
openbox_langchain_sdk_python-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
9
|
+
openbox_langchain_sdk_python-0.1.0.dist-info/RECORD,,
|