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.
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any