openbox-deepagent-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,91 @@
1
+ """
2
+ OpenBox DeepAgents SDK — governance middleware for DeepAgents graphs.
3
+
4
+ Usage:
5
+ >>> from openbox_deepagent import create_openbox_middleware
6
+ >>> middleware = create_openbox_middleware(
7
+ ... api_url=os.environ["OPENBOX_URL"],
8
+ ... api_key=os.environ["OPENBOX_API_KEY"],
9
+ ... agent_name="MyBot",
10
+ ... known_subagents=["researcher"],
11
+ ... )
12
+ >>> agent = create_deep_agent(model="gpt-4o-mini", middleware=[middleware])
13
+ >>> result = await agent.ainvoke({"messages": [...]})
14
+ """
15
+
16
+ from importlib.metadata import PackageNotFoundError, version
17
+
18
+ # Re-export the openbox-langgraph-sdk public surface
19
+ from openbox_langgraph import (
20
+ ApprovalExpiredError,
21
+ ApprovalRejectedError,
22
+ ApprovalTimeoutError,
23
+ GovernanceBlockedError,
24
+ GovernanceConfig,
25
+ GovernanceHaltError,
26
+ GovernanceVerdictResponse,
27
+ GuardrailsValidationError,
28
+ LangChainGovernanceEvent,
29
+ LangGraphStreamEvent,
30
+ OpenBoxAuthError,
31
+ OpenBoxError,
32
+ OpenBoxInsecureURLError,
33
+ OpenBoxLangGraphHandler,
34
+ OpenBoxLangGraphHandlerOptions,
35
+ OpenBoxNetworkError,
36
+ Verdict,
37
+ create_openbox_graph_handler,
38
+ get_global_config,
39
+ initialize,
40
+ rfc3339_now,
41
+ safe_serialize,
42
+ )
43
+
44
+ from openbox_deepagent.middleware import OpenBoxMiddleware, OpenBoxMiddlewareOptions
45
+ from openbox_deepagent.middleware_factory import create_openbox_middleware
46
+ from openbox_deepagent.subagent_resolver import (
47
+ DEEPAGENT_BUILTIN_TOOLS,
48
+ DEEPAGENT_SUBAGENT_TOOL,
49
+ )
50
+
51
+ try:
52
+ __version__ = version("openbox-deepagent-sdk-python")
53
+ except PackageNotFoundError:
54
+ __version__ = "unknown"
55
+
56
+ __all__ = [
57
+ # Shared
58
+ "DEEPAGENT_BUILTIN_TOOLS",
59
+ "DEEPAGENT_SUBAGENT_TOOL",
60
+ "ApprovalExpiredError",
61
+ "ApprovalRejectedError",
62
+ "ApprovalTimeoutError",
63
+ "GovernanceBlockedError",
64
+ "GovernanceConfig",
65
+ "GovernanceHaltError",
66
+ "GovernanceVerdictResponse",
67
+ "GuardrailsValidationError",
68
+ "LangChainGovernanceEvent",
69
+ "LangGraphStreamEvent",
70
+ "OpenBoxAuthError",
71
+ # Errors
72
+ "OpenBoxError",
73
+ "OpenBoxInsecureURLError",
74
+ # Base handler
75
+ "OpenBoxLangGraphHandler",
76
+ "OpenBoxLangGraphHandlerOptions",
77
+ # Middleware API
78
+ "OpenBoxMiddleware",
79
+ "OpenBoxMiddlewareOptions",
80
+ "OpenBoxNetworkError",
81
+ # Types
82
+ "Verdict",
83
+ # Version
84
+ "__version__",
85
+ "create_openbox_graph_handler",
86
+ "create_openbox_middleware",
87
+ "get_global_config",
88
+ "initialize",
89
+ "rfc3339_now",
90
+ "safe_serialize",
91
+ ]
@@ -0,0 +1,354 @@
1
+ """OpenBox DeepAgents Middleware — LangChain AgentMiddleware for governance.
2
+
3
+ Replaces the astream_events-based handler with clean middleware hooks that fire
4
+ at exact execution points in the agent lifecycle:
5
+
6
+ abefore_agent → WorkflowStarted + SignalReceived + pre-screen guardrails
7
+ awrap_model_call → LLMStarted (PII redaction) → Model → LLMCompleted
8
+ awrap_tool_call → ToolStarted → Tool (OTel spans) → ToolCompleted
9
+ aafter_agent → WorkflowCompleted + cleanup
10
+
11
+ Usage:
12
+ from openbox_deepagent import create_openbox_middleware
13
+ middleware = create_openbox_middleware(api_url=..., api_key=..., agent_name="Bot")
14
+ agent = create_deep_agent(model="gpt-4o-mini", middleware=[middleware])
15
+ result = await agent.ainvoke({"messages": [...]})
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import concurrent.futures
22
+ import logging
23
+ from dataclasses import dataclass, field
24
+ from typing import TYPE_CHECKING, Any
25
+
26
+ from langchain.agents.middleware.types import AgentMiddleware, ModelRequest
27
+ from langgraph.prebuilt.tool_node import ToolCallRequest
28
+ from openbox_langgraph.client import GovernanceClient
29
+ from openbox_langgraph.config import GovernanceConfig, get_global_config, merge_config
30
+ from openbox_langgraph.types import GovernanceVerdictResponse
31
+
32
+ if TYPE_CHECKING:
33
+ from openbox_langgraph.span_processor import WorkflowSpanProcessor
34
+
35
+ _logger = logging.getLogger(__name__)
36
+
37
+
38
+ # ═══════════════════════════════════════════════════════════════════
39
+ # Options
40
+ # ═══════════════════════════════════════════════════════════════════
41
+
42
+ @dataclass
43
+ class OpenBoxMiddlewareOptions:
44
+ """Configuration for OpenBoxMiddleware."""
45
+
46
+ agent_name: str | None = None
47
+ session_id: str | None = None
48
+ task_queue: str = "langgraph"
49
+ on_api_error: str = "fail_open"
50
+ governance_timeout: float = 30.0
51
+ known_subagents: list[str] = field(default_factory=lambda: ["general-purpose"])
52
+ tool_type_map: dict[str, str] = field(default_factory=dict)
53
+ skip_tool_types: set[str] = field(default_factory=set)
54
+ sqlalchemy_engine: Any = None
55
+ send_chain_start_event: bool = True
56
+ send_chain_end_event: bool = True
57
+ send_llm_start_event: bool = True
58
+ send_llm_end_event: bool = True
59
+ send_tool_start_event: bool = True
60
+ send_tool_end_event: bool = True
61
+
62
+
63
+ # ═══════════════════════════════════════════════════════════════════
64
+ # OpenBoxMiddleware
65
+ # ═══════════════════════════════════════════════════════════════════
66
+
67
+ class OpenBoxMiddleware(AgentMiddleware):
68
+ """LangChain AgentMiddleware implementing OpenBox governance for DeepAgents.
69
+
70
+ Hooks map directly to the governance event lifecycle:
71
+ - abefore_agent: session setup (WorkflowStarted, SignalReceived, pre-screen)
72
+ - awrap_model_call: LLM governance (LLMStarted/Completed, PII redaction)
73
+ - awrap_tool_call: tool governance (ToolStarted/Completed, SpanProcessor ctx)
74
+ - aafter_agent: session close (WorkflowCompleted, cleanup)
75
+ """
76
+
77
+ def __init__(self, options: OpenBoxMiddlewareOptions | None = None) -> None:
78
+ opts = options or OpenBoxMiddlewareOptions()
79
+ self._options = opts
80
+
81
+ # Build GovernanceConfig from options
82
+ self._config: GovernanceConfig = merge_config({
83
+ "on_api_error": opts.on_api_error,
84
+ "api_timeout": opts.governance_timeout,
85
+ "send_chain_start_event": opts.send_chain_start_event,
86
+ "send_chain_end_event": opts.send_chain_end_event,
87
+ "send_tool_start_event": opts.send_tool_start_event,
88
+ "send_tool_end_event": opts.send_tool_end_event,
89
+ "send_llm_start_event": opts.send_llm_start_event,
90
+ "send_llm_end_event": opts.send_llm_end_event,
91
+ "skip_tool_types": opts.skip_tool_types,
92
+ "session_id": opts.session_id,
93
+ "agent_name": opts.agent_name,
94
+ "task_queue": opts.task_queue,
95
+ "tool_type_map": opts.tool_type_map or {},
96
+ })
97
+
98
+ # Governance client
99
+ gc = get_global_config()
100
+ self._client = GovernanceClient(
101
+ api_url=gc.api_url,
102
+ api_key=gc.api_key,
103
+ timeout=gc.governance_timeout,
104
+ on_api_error=self._config.on_api_error,
105
+ )
106
+
107
+ # OTel span processor for hook-level governance
108
+ self._span_processor: WorkflowSpanProcessor | None = None
109
+ if gc.api_url and gc.api_key:
110
+ from openbox_langgraph.otel_setup import setup_opentelemetry_for_governance
111
+ from openbox_langgraph.span_processor import WorkflowSpanProcessor as WSP
112
+ self._span_processor = WSP()
113
+ setup_opentelemetry_for_governance(
114
+ span_processor=self._span_processor,
115
+ api_url=gc.api_url,
116
+ api_key=gc.api_key,
117
+ ignored_urls=[gc.api_url],
118
+ api_timeout=gc.governance_timeout,
119
+ on_api_error=self._config.on_api_error,
120
+ instrument_file_io=True,
121
+ sqlalchemy_engine=opts.sqlalchemy_engine,
122
+ )
123
+ # Suppress harmless OTel context detach errors from asyncio.Task
124
+ # boundaries in LangGraph — the token was attached in one task
125
+ # but detached in another, which ContextVar rejects.
126
+ logging.getLogger("opentelemetry.context").setLevel(logging.CRITICAL)
127
+ _logger.debug("[OpenBox] OTel HTTP governance hooks enabled (middleware)")
128
+
129
+ self._known_subagents: frozenset[str] = frozenset(opts.known_subagents)
130
+
131
+ # Reusable thread pool for sync-to-async bridge (avoids per-call overhead)
132
+ self._sync_executor: concurrent.futures.ThreadPoolExecutor | None = None
133
+
134
+ # Per-invocation state (reset in before_agent/abefore_agent)
135
+ self._sync_mode: bool = False
136
+ self._workflow_id: str = ""
137
+ self._run_id: str = ""
138
+ self._thread_id: str = ""
139
+ self._pre_screen_response: GovernanceVerdictResponse | None = None
140
+ self._first_llm_call: bool = True
141
+
142
+ # ─────────────────────────────────────────────────────────────
143
+ # Tool classification (ported from langgraph_handler.py)
144
+ # ─────────────────────────────────────────────────────────────
145
+
146
+ def _resolve_tool_type(self, tool_name: str, subagent_name: str | None) -> str | None:
147
+ """Resolve semantic tool_type for a given tool.
148
+
149
+ Priority: 1) explicit tool_type_map, 2) "a2a" if subagent, 3) None
150
+ """
151
+ if tool_name in self._config.tool_type_map:
152
+ return self._config.tool_type_map[tool_name]
153
+ if subagent_name:
154
+ return "a2a"
155
+ return None
156
+
157
+ def _enrich_activity_input(
158
+ self,
159
+ base_input: list[Any] | None,
160
+ tool_type: str | None,
161
+ subagent_name: str | None,
162
+ ) -> list[Any] | None:
163
+ """Append __openbox metadata to activity_input for Rego policy use."""
164
+ if tool_type is None and subagent_name is None:
165
+ return base_input
166
+ meta: dict[str, Any] = {}
167
+ if tool_type is not None:
168
+ meta["tool_type"] = tool_type
169
+ if subagent_name is not None:
170
+ meta["subagent_name"] = subagent_name
171
+ result = list(base_input) if base_input else []
172
+ result.append({"__openbox": meta})
173
+ return result
174
+
175
+ # ─────────────────────────────────────────────────────────────
176
+ # Subagent introspection
177
+ # ─────────────────────────────────────────────────────────────
178
+
179
+ def get_known_subagents(self) -> list[str]:
180
+ """Return the known subagent names registered with this middleware."""
181
+ return sorted(self._known_subagents)
182
+
183
+ # ─────────────────────────────────────────────────────────────
184
+ # Async-to-sync bridge
185
+ # ─────────────────────────────────────────────────────────────
186
+
187
+ def _run_async(self, coro):
188
+ """Run an async coroutine from sync context.
189
+
190
+ When LangGraph calls sync hooks from inside its event loop,
191
+ we must run in a thread pool. We copy the OTel context into
192
+ the thread so span propagation works correctly.
193
+ """
194
+ try:
195
+ loop = asyncio.get_running_loop()
196
+ except RuntimeError:
197
+ loop = None
198
+ if loop and loop.is_running():
199
+ # Inside LangGraph's event loop — run in thread with OTel context
200
+ from opentelemetry import context as otel_context
201
+ ctx = otel_context.get_current()
202
+
203
+ def _run_with_ctx():
204
+ token = otel_context.attach(ctx)
205
+ try:
206
+ return asyncio.run(coro)
207
+ finally:
208
+ try:
209
+ otel_context.detach(token)
210
+ except Exception:
211
+ pass
212
+
213
+ if self._sync_executor is None:
214
+ self._sync_executor = concurrent.futures.ThreadPoolExecutor(max_workers=1)
215
+ return self._sync_executor.submit(_run_with_ctx).result()
216
+ return asyncio.run(coro)
217
+
218
+ # ─────────────────────────────────────────────────────────────
219
+ # Sync middleware hooks — for invoke()/stream() callers
220
+ # ─────────────────────────────────────────────────────────────
221
+
222
+ def before_agent(self, state, runtime) -> dict[str, Any] | None:
223
+ """Sync session setup: delegates to async via _run_async."""
224
+ self._sync_mode = True
225
+ if self._span_processor:
226
+ self._span_processor.set_sync_mode(True)
227
+ from openbox_deepagent.middleware_hooks import handle_before_agent
228
+ return self._run_async(handle_before_agent(self, state, runtime))
229
+
230
+ def after_agent(self, state, runtime) -> dict[str, Any] | None:
231
+ """Sync session close: send WorkflowCompleted via sync httpx."""
232
+ from openbox_deepagent.middleware_hooks import handle_after_agent
233
+ self._run_async(handle_after_agent(self, state, runtime))
234
+ return None
235
+
236
+ def wrap_model_call(self, request: ModelRequest, handler) -> Any:
237
+ """Sync LLM governance with direct OTel span in current thread.
238
+
239
+ Creates OTel span and registers trace_id in the sync thread so httpx
240
+ hooks can find the activity context (avoids asyncio.run ContextVar
241
+ fragmentation).
242
+ """
243
+ from opentelemetry import context as otel_ctx
244
+ from opentelemetry import trace as otel_tr
245
+
246
+ from openbox_deepagent.middleware_hooks import handle_wrap_model_call
247
+
248
+ # Create OTel span in sync thread for httpx hook visibility
249
+ tracer = otel_tr.get_tracer("openbox-deepagent")
250
+ span = tracer.start_span("llm.call.sync", kind=otel_tr.SpanKind.INTERNAL)
251
+ token = otel_ctx.attach(otel_tr.set_span_in_context(span))
252
+ trace_id = span.get_span_context().trace_id
253
+ activity_id = None
254
+
255
+ try:
256
+ # Run the async governance handler — it will register its own
257
+ # trace_id via _run_with_otel_context, but we also register the
258
+ # sync thread's trace_id so httpx sync hooks can find it
259
+ async def async_handler(req):
260
+ return handler(req)
261
+
262
+ async def _wrapped():
263
+ nonlocal activity_id
264
+ # Import here to get the activity_id from the handler
265
+ import uuid
266
+ activity_id = str(uuid.uuid4())
267
+ if self._span_processor and trace_id:
268
+ self._span_processor.register_trace(trace_id, self._workflow_id, activity_id)
269
+ self._span_processor.set_activity_context(self._workflow_id, activity_id, {
270
+ "source": "workflow-telemetry",
271
+ "workflow_id": self._workflow_id,
272
+ "run_id": self._run_id,
273
+ "event_type": "ActivityStarted",
274
+ "activity_id": activity_id,
275
+ "activity_type": "llm_call",
276
+ })
277
+ return await handle_wrap_model_call(self, request, async_handler)
278
+
279
+ return self._run_async(_wrapped())
280
+ finally:
281
+ span.end()
282
+ try:
283
+ otel_ctx.detach(token)
284
+ except Exception:
285
+ pass
286
+
287
+ def wrap_tool_call(self, request: ToolCallRequest, handler) -> Any:
288
+ """Sync tool governance with direct OTel span in current thread."""
289
+ from opentelemetry import context as otel_ctx
290
+ from opentelemetry import trace as otel_tr
291
+
292
+ from openbox_deepagent.middleware_hooks import handle_wrap_tool_call
293
+
294
+ tracer = otel_tr.get_tracer("openbox-deepagent")
295
+ tool_name = (
296
+ request.tool_call.get("name", "tool")
297
+ if hasattr(request, "tool_call") else "tool"
298
+ )
299
+ span = tracer.start_span(f"tool.{tool_name}.sync", kind=otel_tr.SpanKind.INTERNAL)
300
+ token = otel_ctx.attach(otel_tr.set_span_in_context(span))
301
+ trace_id = span.get_span_context().trace_id
302
+
303
+ try:
304
+ async def async_handler(req):
305
+ return handler(req)
306
+
307
+ # Register sync thread trace_id before running handler
308
+ if self._span_processor and trace_id:
309
+ import uuid
310
+ sync_activity_id = str(uuid.uuid4())
311
+ self._span_processor.register_trace(trace_id, self._workflow_id, sync_activity_id)
312
+ self._span_processor.set_activity_context(self._workflow_id, sync_activity_id, {
313
+ "source": "workflow-telemetry",
314
+ "workflow_id": self._workflow_id,
315
+ "run_id": self._run_id,
316
+ "event_type": "ActivityStarted",
317
+ "activity_id": sync_activity_id,
318
+ "activity_type": tool_name,
319
+ })
320
+
321
+ return self._run_async(handle_wrap_tool_call(self, request, async_handler))
322
+ finally:
323
+ span.end()
324
+ try:
325
+ otel_ctx.detach(token)
326
+ except Exception:
327
+ pass
328
+
329
+ # ─────────────────────────────────────────────────────────────
330
+ # Async middleware hooks — for ainvoke()/astream() callers
331
+ # ─────────────────────────────────────────────────────────────
332
+
333
+ async def abefore_agent(self, state, runtime) -> dict[str, Any] | None:
334
+ """Session setup: WorkflowStarted + SignalReceived + pre-screen guardrails."""
335
+ self._sync_mode = False
336
+ if self._span_processor:
337
+ self._span_processor.set_sync_mode(False)
338
+ from openbox_deepagent.middleware_hooks import handle_before_agent
339
+ return await handle_before_agent(self, state, runtime)
340
+
341
+ async def aafter_agent(self, state, runtime) -> dict[str, Any] | None:
342
+ """Session close: WorkflowCompleted + cleanup."""
343
+ from openbox_deepagent.middleware_hooks import handle_after_agent
344
+ return await handle_after_agent(self, state, runtime)
345
+
346
+ async def awrap_model_call(self, request: ModelRequest, handler) -> Any:
347
+ """LLM governance: LLMStarted → PII redaction → Model → LLMCompleted."""
348
+ from openbox_deepagent.middleware_hooks import handle_wrap_model_call
349
+ return await handle_wrap_model_call(self, request, handler)
350
+
351
+ async def awrap_tool_call(self, request: ToolCallRequest, handler) -> Any:
352
+ """Tool governance: ToolStarted → Tool (OTel spans) → ToolCompleted."""
353
+ from openbox_deepagent.middleware_hooks import handle_wrap_tool_call
354
+ return await handle_wrap_tool_call(self, request, handler)
@@ -0,0 +1,74 @@
1
+ """Factory for creating configured OpenBoxMiddleware instances.
2
+
3
+ Usage:
4
+ middleware = create_openbox_middleware(
5
+ api_url=os.environ["OPENBOX_URL"],
6
+ api_key=os.environ["OPENBOX_API_KEY"],
7
+ agent_name="ResearchBot",
8
+ known_subagents=["researcher", "writer"],
9
+ )
10
+ agent = create_deep_agent(
11
+ model=init_chat_model("openai:gpt-4o-mini"),
12
+ middleware=[middleware],
13
+ tools=[...],
14
+ )
15
+ result = await agent.ainvoke({"messages": [...]})
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import dataclasses
21
+ from typing import Any
22
+
23
+ from openbox_deepagent.middleware import OpenBoxMiddleware, OpenBoxMiddlewareOptions
24
+
25
+
26
+ def create_openbox_middleware(
27
+ *,
28
+ api_url: str,
29
+ api_key: str,
30
+ agent_name: str | None = None,
31
+ governance_timeout: float = 30.0,
32
+ validate: bool = True,
33
+ known_subagents: list[str] | None = None,
34
+ sqlalchemy_engine: Any = None,
35
+ **kwargs: Any,
36
+ ) -> OpenBoxMiddleware:
37
+ """Create a configured OpenBoxMiddleware for create_deep_agent(middleware=[...]).
38
+
39
+ Validates the API key and sets up global config before returning the middleware.
40
+
41
+ Args:
42
+ api_url: Base URL of your OpenBox Core instance.
43
+ api_key: API key in ``obx_live_*`` or ``obx_test_*`` format.
44
+ agent_name: Agent name as configured in the dashboard.
45
+ governance_timeout: HTTP timeout in seconds for governance calls (default 30.0).
46
+ validate: If True, validates the API key against the server on startup.
47
+ known_subagents: Subagent names from ``create_deep_agent(subagents=[...])``.
48
+ Defaults to ``["general-purpose"]``.
49
+ sqlalchemy_engine: Optional SQLAlchemy Engine instance to instrument for DB
50
+ governance. Required when the engine is created before the middleware
51
+ (e.g. ``SQLDatabase.from_uri()``). Without this, only engines created
52
+ after middleware initialization will be instrumented.
53
+ **kwargs: Additional keyword arguments forwarded to ``OpenBoxMiddlewareOptions``.
54
+
55
+ Returns:
56
+ A configured ``OpenBoxMiddleware`` ready for injection into create_deep_agent.
57
+ """
58
+ from openbox_langgraph.config import initialize
59
+ initialize(
60
+ api_url=api_url,
61
+ api_key=api_key,
62
+ governance_timeout=governance_timeout,
63
+ validate=validate,
64
+ )
65
+
66
+ valid_fields = {f.name for f in dataclasses.fields(OpenBoxMiddlewareOptions)}
67
+ options = OpenBoxMiddlewareOptions(
68
+ agent_name=agent_name,
69
+ governance_timeout=governance_timeout,
70
+ known_subagents=known_subagents or ["general-purpose"],
71
+ sqlalchemy_engine=sqlalchemy_engine,
72
+ **{k: v for k, v in kwargs.items() if k in valid_fields},
73
+ )
74
+ return OpenBoxMiddleware(options)