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.
- openbox_deepagent/__init__.py +91 -0
- openbox_deepagent/middleware.py +354 -0
- openbox_deepagent/middleware_factory.py +74 -0
- openbox_deepagent/middleware_hooks.py +783 -0
- openbox_deepagent/py.typed +0 -0
- openbox_deepagent/subagent_resolver.py +130 -0
- openbox_deepagent_sdk_python-0.1.0.dist-info/METADATA +739 -0
- openbox_deepagent_sdk_python-0.1.0.dist-info/RECORD +9 -0
- openbox_deepagent_sdk_python-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,783 @@
|
|
|
1
|
+
"""Hook implementations for OpenBoxMiddleware.
|
|
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
|
+
import time
|
|
14
|
+
import uuid
|
|
15
|
+
from typing import TYPE_CHECKING, Any
|
|
16
|
+
|
|
17
|
+
from openbox_langgraph.errors import (
|
|
18
|
+
ApprovalExpiredError,
|
|
19
|
+
ApprovalRejectedError,
|
|
20
|
+
GovernanceBlockedError,
|
|
21
|
+
GovernanceHaltError,
|
|
22
|
+
)
|
|
23
|
+
from openbox_langgraph.hitl import HITLPollParams, poll_until_decision
|
|
24
|
+
from openbox_langgraph.types import (
|
|
25
|
+
LangChainGovernanceEvent,
|
|
26
|
+
rfc3339_now,
|
|
27
|
+
safe_serialize,
|
|
28
|
+
)
|
|
29
|
+
from openbox_langgraph.verdict_handler import enforce_verdict
|
|
30
|
+
from opentelemetry import context as otel_context
|
|
31
|
+
from opentelemetry import trace as otel_trace
|
|
32
|
+
|
|
33
|
+
from openbox_deepagent.subagent_resolver import (
|
|
34
|
+
resolve_subagent_from_tool_call,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
_tracer = otel_trace.get_tracer("openbox-deepagent")
|
|
38
|
+
_logger = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
from openbox_deepagent.middleware import OpenBoxMiddleware
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _extract_governance_blocked(exc: Exception) -> GovernanceBlockedError | None:
|
|
45
|
+
"""Walk exception chain to find a wrapped GovernanceBlockedError.
|
|
46
|
+
|
|
47
|
+
LLM SDKs (OpenAI, Anthropic) wrap httpx errors. When an OTel hook raises
|
|
48
|
+
GovernanceBlockedError inside httpx, the LLM SDK wraps it as APIConnectionError.
|
|
49
|
+
This function unwraps the chain via __cause__ / __context__ to recover it.
|
|
50
|
+
"""
|
|
51
|
+
cause: BaseException | None = exc
|
|
52
|
+
seen: set[int] = set()
|
|
53
|
+
while cause is not None:
|
|
54
|
+
if id(cause) in seen:
|
|
55
|
+
break
|
|
56
|
+
seen.add(id(cause))
|
|
57
|
+
if isinstance(cause, GovernanceBlockedError):
|
|
58
|
+
return cause
|
|
59
|
+
cause = getattr(cause, '__cause__', None) or getattr(cause, '__context__', None)
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
64
|
+
# Helper: evaluate event (sync or async based on mode)
|
|
65
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
66
|
+
|
|
67
|
+
async def _evaluate(mw: OpenBoxMiddleware, event: Any) -> Any:
|
|
68
|
+
"""Send governance event using sync httpx.Client when in sync mode,
|
|
69
|
+
async httpx.AsyncClient otherwise. Prevents context cancellation
|
|
70
|
+
caused by asyncio.run() teardown in sync-to-async bridge."""
|
|
71
|
+
if mw._sync_mode:
|
|
72
|
+
return mw._client.evaluate_event_sync(event)
|
|
73
|
+
return await mw._client.evaluate_event(event)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def _poll_approval_or_halt(
|
|
77
|
+
mw: OpenBoxMiddleware,
|
|
78
|
+
activity_id: str,
|
|
79
|
+
activity_type: str,
|
|
80
|
+
) -> None:
|
|
81
|
+
"""Poll for HITL approval, clearing abort state first.
|
|
82
|
+
|
|
83
|
+
On rejection/expiry, clears SpanProcessor context and raises GovernanceHaltError.
|
|
84
|
+
On approval, returns normally so the caller can retry.
|
|
85
|
+
"""
|
|
86
|
+
if mw._span_processor:
|
|
87
|
+
mw._span_processor.clear_activity_abort(mw._workflow_id, activity_id)
|
|
88
|
+
try:
|
|
89
|
+
await poll_until_decision(
|
|
90
|
+
mw._client,
|
|
91
|
+
HITLPollParams(
|
|
92
|
+
workflow_id=mw._workflow_id, run_id=mw._run_id,
|
|
93
|
+
activity_id=activity_id, activity_type=activity_type,
|
|
94
|
+
),
|
|
95
|
+
mw._config.hitl,
|
|
96
|
+
)
|
|
97
|
+
except (ApprovalRejectedError, ApprovalExpiredError) as e:
|
|
98
|
+
if mw._span_processor:
|
|
99
|
+
mw._span_processor.clear_activity_context(mw._workflow_id, activity_id)
|
|
100
|
+
raise GovernanceHaltError(str(e)) from e
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
104
|
+
# Helper: build base governance event fields
|
|
105
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
106
|
+
|
|
107
|
+
def _base_event_fields(mw: OpenBoxMiddleware) -> dict[str, Any]:
|
|
108
|
+
"""Return common fields for all governance events."""
|
|
109
|
+
return {
|
|
110
|
+
"source": "workflow-telemetry",
|
|
111
|
+
"workflow_id": mw._workflow_id,
|
|
112
|
+
"run_id": mw._run_id,
|
|
113
|
+
"workflow_type": mw._config.agent_name or "LangGraphRun",
|
|
114
|
+
"task_queue": mw._config.task_queue,
|
|
115
|
+
"timestamp": rfc3339_now(),
|
|
116
|
+
"session_id": mw._config.session_id,
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
121
|
+
# Helper: extract last user message from state
|
|
122
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
123
|
+
|
|
124
|
+
def _extract_last_user_message(messages: list[Any]) -> str | None:
|
|
125
|
+
"""Extract the last human/user message text from agent state messages."""
|
|
126
|
+
for msg in reversed(messages):
|
|
127
|
+
if isinstance(msg, dict):
|
|
128
|
+
if msg.get("role") in ("user", "human"):
|
|
129
|
+
content = msg.get("content")
|
|
130
|
+
return content if isinstance(content, str) else None
|
|
131
|
+
elif hasattr(msg, "type") and msg.type in ("human", "generic"):
|
|
132
|
+
content = msg.content
|
|
133
|
+
return content if isinstance(content, str) else None
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
138
|
+
# Helper: extract prompt from LangChain messages
|
|
139
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
140
|
+
|
|
141
|
+
def _extract_prompt_from_messages(messages: Any) -> str:
|
|
142
|
+
"""Extract human/user message text from a messages list."""
|
|
143
|
+
if not isinstance(messages, (list, tuple)):
|
|
144
|
+
return ""
|
|
145
|
+
parts: list[str] = []
|
|
146
|
+
for msg in messages:
|
|
147
|
+
# Nested list of messages
|
|
148
|
+
if isinstance(msg, (list, tuple)):
|
|
149
|
+
for inner in msg:
|
|
150
|
+
_append_human_content(inner, parts)
|
|
151
|
+
else:
|
|
152
|
+
_append_human_content(msg, parts)
|
|
153
|
+
return "\n".join(parts)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _append_human_content(msg: Any, parts: list[str]) -> None:
|
|
157
|
+
"""Append human message content to parts list."""
|
|
158
|
+
role = None
|
|
159
|
+
content = None
|
|
160
|
+
if hasattr(msg, "type"):
|
|
161
|
+
role = msg.type
|
|
162
|
+
content = msg.content
|
|
163
|
+
elif isinstance(msg, dict):
|
|
164
|
+
role = msg.get("role") or msg.get("type", "")
|
|
165
|
+
content = msg.get("content", "")
|
|
166
|
+
if role not in ("human", "user", "generic"):
|
|
167
|
+
return
|
|
168
|
+
if isinstance(content, str):
|
|
169
|
+
parts.append(content)
|
|
170
|
+
elif isinstance(content, list):
|
|
171
|
+
for part in content:
|
|
172
|
+
if isinstance(part, dict) and part.get("type") == "text":
|
|
173
|
+
parts.append(part.get("text", ""))
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
177
|
+
# Helper: PII redaction
|
|
178
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
179
|
+
|
|
180
|
+
def _apply_pii_redaction(messages: list[Any], redacted_input: Any) -> None:
|
|
181
|
+
"""Apply PII redaction to messages in-place from guardrails response."""
|
|
182
|
+
# Extract redacted text from Core's format: [{"prompt": "..."}] or string
|
|
183
|
+
redacted_text = None
|
|
184
|
+
if isinstance(redacted_input, list) and redacted_input:
|
|
185
|
+
first = redacted_input[0]
|
|
186
|
+
if isinstance(first, dict):
|
|
187
|
+
redacted_text = first.get("prompt")
|
|
188
|
+
elif isinstance(first, str):
|
|
189
|
+
redacted_text = first
|
|
190
|
+
elif isinstance(redacted_input, str):
|
|
191
|
+
redacted_text = redacted_input
|
|
192
|
+
|
|
193
|
+
if not redacted_text:
|
|
194
|
+
return
|
|
195
|
+
|
|
196
|
+
# Replace the last human message in the list
|
|
197
|
+
for i in range(len(messages) - 1, -1, -1):
|
|
198
|
+
msg = messages[i]
|
|
199
|
+
if hasattr(msg, "type") and msg.type in ("human", "generic"):
|
|
200
|
+
msg.content = redacted_text
|
|
201
|
+
break
|
|
202
|
+
elif isinstance(msg, dict) and msg.get("role") in ("user", "human"):
|
|
203
|
+
msg["content"] = redacted_text
|
|
204
|
+
break
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
208
|
+
# Helper: extract token usage from model response
|
|
209
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
210
|
+
|
|
211
|
+
def _extract_response_metadata(response: Any) -> dict[str, Any]:
|
|
212
|
+
"""Extract tokens, model name, completion, tool_calls from model response."""
|
|
213
|
+
result: dict[str, Any] = {}
|
|
214
|
+
|
|
215
|
+
# Try to get the AIMessage from ModelResponse or directly
|
|
216
|
+
ai_msg = response
|
|
217
|
+
if hasattr(response, "message"):
|
|
218
|
+
ai_msg = response.message
|
|
219
|
+
|
|
220
|
+
# Model name
|
|
221
|
+
if hasattr(ai_msg, "response_metadata"):
|
|
222
|
+
meta = ai_msg.response_metadata or {}
|
|
223
|
+
result["llm_model"] = meta.get("model_name") or meta.get("model")
|
|
224
|
+
|
|
225
|
+
# Token usage
|
|
226
|
+
usage = getattr(ai_msg, "usage_metadata", None) or {}
|
|
227
|
+
if isinstance(usage, dict):
|
|
228
|
+
result["input_tokens"] = usage.get("input_tokens") or usage.get("prompt_tokens")
|
|
229
|
+
result["output_tokens"] = usage.get("output_tokens") or usage.get("completion_tokens")
|
|
230
|
+
inp = result.get("input_tokens") or 0
|
|
231
|
+
out = result.get("output_tokens") or 0
|
|
232
|
+
result["total_tokens"] = inp + out if (inp or out) else None
|
|
233
|
+
|
|
234
|
+
# Completion text
|
|
235
|
+
content = getattr(ai_msg, "content", None)
|
|
236
|
+
if isinstance(content, str):
|
|
237
|
+
result["completion"] = content
|
|
238
|
+
elif isinstance(content, list):
|
|
239
|
+
parts = [
|
|
240
|
+
p.get("text", "") for p in content
|
|
241
|
+
if isinstance(p, dict) and p.get("type") == "text"
|
|
242
|
+
]
|
|
243
|
+
result["completion"] = " ".join(parts) if parts else None
|
|
244
|
+
|
|
245
|
+
# Tool calls
|
|
246
|
+
tool_calls = getattr(ai_msg, "tool_calls", None) or []
|
|
247
|
+
result["has_tool_calls"] = bool(tool_calls)
|
|
248
|
+
|
|
249
|
+
return result
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
253
|
+
# Helper: OTel context propagation across asyncio.Task boundaries
|
|
254
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
255
|
+
|
|
256
|
+
async def _run_with_otel_context(
|
|
257
|
+
mw: OpenBoxMiddleware,
|
|
258
|
+
span_name: str,
|
|
259
|
+
activity_id: str,
|
|
260
|
+
handler: Any,
|
|
261
|
+
request: Any,
|
|
262
|
+
) -> Any:
|
|
263
|
+
"""Execute handler inside an explicit OTel span to propagate trace context.
|
|
264
|
+
|
|
265
|
+
LangGraph spawns asyncio.Tasks for tool/LLM execution. OTel trace context
|
|
266
|
+
breaks at Task boundaries — child spans get new trace_ids.
|
|
267
|
+
|
|
268
|
+
We manually manage attach/detach instead of using `start_as_current_span`
|
|
269
|
+
context manager because the `await handler(request)` may cross asyncio Task
|
|
270
|
+
boundaries, causing the detach token to be invalid in the new Task context.
|
|
271
|
+
The detach error is harmless but noisy — suppressing it here.
|
|
272
|
+
"""
|
|
273
|
+
parent_ctx = otel_context.get_current()
|
|
274
|
+
span = _tracer.start_span(span_name, context=parent_ctx, kind=otel_trace.SpanKind.INTERNAL)
|
|
275
|
+
token = otel_context.attach(otel_trace.set_span_in_context(span, parent_ctx))
|
|
276
|
+
|
|
277
|
+
trace_id = span.get_span_context().trace_id
|
|
278
|
+
if mw._span_processor and trace_id:
|
|
279
|
+
mw._span_processor.register_trace(trace_id, mw._workflow_id, activity_id)
|
|
280
|
+
|
|
281
|
+
try:
|
|
282
|
+
result = await handler(request)
|
|
283
|
+
return result
|
|
284
|
+
finally:
|
|
285
|
+
span.end()
|
|
286
|
+
try:
|
|
287
|
+
otel_context.detach(token)
|
|
288
|
+
except Exception:
|
|
289
|
+
pass # Token created in different asyncio context — safe to ignore
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
293
|
+
# Hook: abefore_agent
|
|
294
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
295
|
+
|
|
296
|
+
async def handle_before_agent(
|
|
297
|
+
mw: OpenBoxMiddleware, state: Any, runtime: Any,
|
|
298
|
+
) -> dict[str, Any] | None:
|
|
299
|
+
"""Session setup: SignalReceived + WorkflowStarted + pre-screen guardrails.
|
|
300
|
+
|
|
301
|
+
Fires once per invoke() before any model calls.
|
|
302
|
+
"""
|
|
303
|
+
# 1. Extract thread_id and generate fresh session IDs
|
|
304
|
+
config = getattr(runtime, "config", None) or {}
|
|
305
|
+
configurable = config.get("configurable", {}) if isinstance(config, dict) else {}
|
|
306
|
+
mw._thread_id = configurable.get("thread_id", "deepagents")
|
|
307
|
+
_turn = uuid.uuid4().hex
|
|
308
|
+
mw._workflow_id = f"{mw._thread_id}-{_turn[:8]}"
|
|
309
|
+
mw._run_id = f"{mw._thread_id}-run-{_turn[8:16]}"
|
|
310
|
+
mw._first_llm_call = True
|
|
311
|
+
mw._pre_screen_response = None
|
|
312
|
+
|
|
313
|
+
base = _base_event_fields(mw)
|
|
314
|
+
messages = (
|
|
315
|
+
state.get("messages", []) if isinstance(state, dict)
|
|
316
|
+
else getattr(state, "messages", [])
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
# 2. SignalReceived — user prompt as trigger
|
|
320
|
+
user_prompt = _extract_last_user_message(messages)
|
|
321
|
+
if user_prompt:
|
|
322
|
+
sig_event = LangChainGovernanceEvent(
|
|
323
|
+
**base,
|
|
324
|
+
event_type="SignalReceived",
|
|
325
|
+
activity_id=f"{mw._run_id}-sig",
|
|
326
|
+
activity_type="user_prompt",
|
|
327
|
+
signal_name="user_prompt",
|
|
328
|
+
signal_args=[user_prompt],
|
|
329
|
+
)
|
|
330
|
+
await _evaluate(mw,sig_event)
|
|
331
|
+
|
|
332
|
+
# 3. WorkflowStarted
|
|
333
|
+
if mw._config.send_chain_start_event:
|
|
334
|
+
wf_event = LangChainGovernanceEvent(
|
|
335
|
+
**base,
|
|
336
|
+
event_type="WorkflowStarted",
|
|
337
|
+
activity_id=f"{mw._run_id}-wf",
|
|
338
|
+
activity_type=mw._config.agent_name or "LangGraphRun",
|
|
339
|
+
activity_input=[safe_serialize(state)],
|
|
340
|
+
)
|
|
341
|
+
await _evaluate(mw,wf_event)
|
|
342
|
+
|
|
343
|
+
# 4. Pre-screen LLMStarted (guardrails on user prompt)
|
|
344
|
+
if mw._config.send_llm_start_event and user_prompt and user_prompt.strip():
|
|
345
|
+
gov = LangChainGovernanceEvent(
|
|
346
|
+
**base,
|
|
347
|
+
event_type="LLMStarted",
|
|
348
|
+
activity_id=f"{mw._run_id}-pre",
|
|
349
|
+
activity_type="llm_call",
|
|
350
|
+
activity_input=[{"prompt": user_prompt}],
|
|
351
|
+
prompt=user_prompt,
|
|
352
|
+
)
|
|
353
|
+
response = await _evaluate(mw,gov)
|
|
354
|
+
|
|
355
|
+
if response is not None:
|
|
356
|
+
# Enforce — BLOCK/HALT raises immediately
|
|
357
|
+
enforcement_error: Exception | None = None
|
|
358
|
+
try:
|
|
359
|
+
result = enforce_verdict(response, "llm_start")
|
|
360
|
+
except Exception as exc:
|
|
361
|
+
enforcement_error = exc
|
|
362
|
+
|
|
363
|
+
# Close workflow on enforcement error
|
|
364
|
+
if enforcement_error is not None and mw._config.send_chain_end_event:
|
|
365
|
+
wf_end = LangChainGovernanceEvent(
|
|
366
|
+
**_base_event_fields(mw),
|
|
367
|
+
event_type="WorkflowCompleted",
|
|
368
|
+
activity_id=f"{mw._run_id}-wf",
|
|
369
|
+
activity_type=mw._config.agent_name or "LangGraphRun",
|
|
370
|
+
status="failed",
|
|
371
|
+
error=str(enforcement_error),
|
|
372
|
+
)
|
|
373
|
+
await _evaluate(mw,wf_end)
|
|
374
|
+
raise enforcement_error
|
|
375
|
+
|
|
376
|
+
# HITL polling if needed
|
|
377
|
+
if result and result.requires_hitl:
|
|
378
|
+
try:
|
|
379
|
+
await poll_until_decision(
|
|
380
|
+
mw._client,
|
|
381
|
+
HITLPollParams(
|
|
382
|
+
workflow_id=mw._workflow_id,
|
|
383
|
+
run_id=mw._run_id,
|
|
384
|
+
activity_id=f"{mw._run_id}-pre",
|
|
385
|
+
activity_type="llm_call",
|
|
386
|
+
),
|
|
387
|
+
mw._config.hitl,
|
|
388
|
+
)
|
|
389
|
+
except (ApprovalRejectedError, ApprovalExpiredError) as e:
|
|
390
|
+
raise GovernanceHaltError(str(e)) from e
|
|
391
|
+
|
|
392
|
+
mw._pre_screen_response = response
|
|
393
|
+
|
|
394
|
+
return None
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
398
|
+
# Hook: aafter_agent
|
|
399
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
400
|
+
|
|
401
|
+
async def handle_after_agent(
|
|
402
|
+
mw: OpenBoxMiddleware, state: Any, runtime: Any,
|
|
403
|
+
) -> dict[str, Any] | None:
|
|
404
|
+
"""Session close: WorkflowCompleted + cleanup.
|
|
405
|
+
|
|
406
|
+
Fires once per invoke() after agent completes.
|
|
407
|
+
"""
|
|
408
|
+
if mw._config.send_chain_end_event:
|
|
409
|
+
messages = (
|
|
410
|
+
state.get("messages", []) if isinstance(state, dict)
|
|
411
|
+
else getattr(state, "messages", [])
|
|
412
|
+
)
|
|
413
|
+
last_content = None
|
|
414
|
+
if messages:
|
|
415
|
+
last_msg = messages[-1]
|
|
416
|
+
last_content = getattr(last_msg, "content", None) if hasattr(last_msg, "content") else (
|
|
417
|
+
last_msg.get("content") if isinstance(last_msg, dict) else None
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
wf_event = LangChainGovernanceEvent(
|
|
421
|
+
**_base_event_fields(mw),
|
|
422
|
+
event_type="WorkflowCompleted",
|
|
423
|
+
activity_id=f"{mw._run_id}-wf",
|
|
424
|
+
activity_type=mw._config.agent_name or "LangGraphRun",
|
|
425
|
+
workflow_output=safe_serialize({"result": last_content}),
|
|
426
|
+
status="completed",
|
|
427
|
+
)
|
|
428
|
+
await _evaluate(mw,wf_event)
|
|
429
|
+
|
|
430
|
+
# Cleanup SpanProcessor state
|
|
431
|
+
if mw._span_processor:
|
|
432
|
+
mw._span_processor.unregister_workflow(mw._workflow_id)
|
|
433
|
+
|
|
434
|
+
return None
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
438
|
+
# Hook: awrap_model_call
|
|
439
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
440
|
+
|
|
441
|
+
async def handle_wrap_model_call(mw: OpenBoxMiddleware, request: Any, handler: Any) -> Any:
|
|
442
|
+
"""LLM governance: LLMStarted → PII redaction → Model → LLMCompleted.
|
|
443
|
+
|
|
444
|
+
Wraps each LLM call within the agent loop.
|
|
445
|
+
"""
|
|
446
|
+
# 1. Extract prompt from request messages
|
|
447
|
+
prompt_text = _extract_prompt_from_messages(request.messages)
|
|
448
|
+
|
|
449
|
+
# 2. Skip governance for empty prompts (subagent internal LLMs)
|
|
450
|
+
if not prompt_text.strip():
|
|
451
|
+
return await handler(request)
|
|
452
|
+
|
|
453
|
+
base = _base_event_fields(mw)
|
|
454
|
+
activity_id = str(uuid.uuid4())
|
|
455
|
+
|
|
456
|
+
# 3. LLMStarted — reuse pre_screen for first call
|
|
457
|
+
if mw._first_llm_call and mw._pre_screen_response is not None:
|
|
458
|
+
response = mw._pre_screen_response
|
|
459
|
+
mw._pre_screen_response = None
|
|
460
|
+
mw._first_llm_call = False
|
|
461
|
+
activity_id = f"{mw._run_id}-pre"
|
|
462
|
+
else:
|
|
463
|
+
mw._first_llm_call = False
|
|
464
|
+
if mw._config.send_llm_start_event:
|
|
465
|
+
model_name = (
|
|
466
|
+
str(request.model)
|
|
467
|
+
if hasattr(request, "model") and request.model
|
|
468
|
+
else "LLM"
|
|
469
|
+
)
|
|
470
|
+
gov = LangChainGovernanceEvent(
|
|
471
|
+
**base,
|
|
472
|
+
event_type="LLMStarted",
|
|
473
|
+
activity_id=activity_id,
|
|
474
|
+
activity_type="llm_call",
|
|
475
|
+
activity_input=[{"prompt": prompt_text}],
|
|
476
|
+
llm_model=model_name,
|
|
477
|
+
prompt=prompt_text,
|
|
478
|
+
)
|
|
479
|
+
response = await _evaluate(mw,gov)
|
|
480
|
+
else:
|
|
481
|
+
response = None
|
|
482
|
+
|
|
483
|
+
# 4. Apply PII redaction to request messages
|
|
484
|
+
if response and response.guardrails_result:
|
|
485
|
+
gr = response.guardrails_result
|
|
486
|
+
if gr.input_type == "activity_input" and gr.redacted_input is not None:
|
|
487
|
+
_apply_pii_redaction(request.messages, gr.redacted_input)
|
|
488
|
+
|
|
489
|
+
# 5. Register SpanProcessor context for LLM call
|
|
490
|
+
if mw._span_processor:
|
|
491
|
+
mw._span_processor.set_activity_context(mw._workflow_id, activity_id, {
|
|
492
|
+
**base,
|
|
493
|
+
"event_type": "ActivityStarted",
|
|
494
|
+
"activity_id": activity_id,
|
|
495
|
+
"activity_type": "llm_call",
|
|
496
|
+
})
|
|
497
|
+
|
|
498
|
+
# 6. Execute model call (OTel span bridges asyncio.Task boundary)
|
|
499
|
+
# Retry loop: hooks may return REQUIRE_APPROVAL multiple times (different
|
|
500
|
+
# span types). Each approval triggers a retry. No client-side deadline.
|
|
501
|
+
start = time.monotonic()
|
|
502
|
+
while True:
|
|
503
|
+
try:
|
|
504
|
+
model_response = await _run_with_otel_context(
|
|
505
|
+
mw, "llm.call", activity_id, handler, request,
|
|
506
|
+
)
|
|
507
|
+
break # success
|
|
508
|
+
except GovernanceBlockedError as hook_err:
|
|
509
|
+
if hook_err.verdict != "require_approval":
|
|
510
|
+
raise
|
|
511
|
+
_logger.info("[OpenBox] Hook REQUIRE_APPROVAL during activity=llm_call, polling")
|
|
512
|
+
await _poll_approval_or_halt(mw, activity_id, "llm_call")
|
|
513
|
+
_logger.info("[OpenBox] Approval granted, retrying activity=llm_call")
|
|
514
|
+
except Exception as exc:
|
|
515
|
+
hook_err = _extract_governance_blocked(exc)
|
|
516
|
+
if hook_err is None or hook_err.verdict != "require_approval":
|
|
517
|
+
raise
|
|
518
|
+
_logger.info(
|
|
519
|
+
"[OpenBox] Hook REQUIRE_APPROVAL (wrapped) "
|
|
520
|
+
"during activity=llm_call, polling",
|
|
521
|
+
)
|
|
522
|
+
await _poll_approval_or_halt(mw, activity_id, "llm_call")
|
|
523
|
+
_logger.info("[OpenBox] Approval granted, retrying activity=llm_call")
|
|
524
|
+
duration_ms = (time.monotonic() - start) * 1000
|
|
525
|
+
|
|
526
|
+
# 7. Send LLMCompleted
|
|
527
|
+
_logger.debug(
|
|
528
|
+
"[OpenBox] wrap_model_call AFTER: activity_id=%s "
|
|
529
|
+
"duration=%.0fms send_llm_end=%s",
|
|
530
|
+
activity_id, duration_ms, mw._config.send_llm_end_event,
|
|
531
|
+
)
|
|
532
|
+
if mw._config.send_llm_end_event:
|
|
533
|
+
meta = _extract_response_metadata(model_response)
|
|
534
|
+
completed = LangChainGovernanceEvent(
|
|
535
|
+
**_base_event_fields(mw),
|
|
536
|
+
event_type="LLMCompleted",
|
|
537
|
+
activity_id=f"{activity_id}-c",
|
|
538
|
+
activity_type="llm_call",
|
|
539
|
+
activity_output=(
|
|
540
|
+
safe_serialize(model_response)
|
|
541
|
+
if hasattr(model_response, "__dict__") else None
|
|
542
|
+
),
|
|
543
|
+
status="completed",
|
|
544
|
+
duration_ms=duration_ms,
|
|
545
|
+
llm_model=meta.get("llm_model"),
|
|
546
|
+
input_tokens=meta.get("input_tokens"),
|
|
547
|
+
output_tokens=meta.get("output_tokens"),
|
|
548
|
+
total_tokens=meta.get("total_tokens"),
|
|
549
|
+
has_tool_calls=meta.get("has_tool_calls"),
|
|
550
|
+
completion=meta.get("completion"),
|
|
551
|
+
)
|
|
552
|
+
_logger.debug("[OpenBox] LLMCompleted SENDING: activity_id=%s-c", activity_id)
|
|
553
|
+
resp = await _evaluate(mw,completed)
|
|
554
|
+
_logger.debug("[OpenBox] LLMCompleted SENT: activity_id=%s-c resp=%s", activity_id, resp)
|
|
555
|
+
if resp is not None:
|
|
556
|
+
enforce_verdict(resp, "llm_end")
|
|
557
|
+
|
|
558
|
+
# 8. Clear SpanProcessor context
|
|
559
|
+
if mw._span_processor:
|
|
560
|
+
mw._span_processor.clear_activity_context(mw._workflow_id, activity_id)
|
|
561
|
+
|
|
562
|
+
return model_response
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
566
|
+
# Hook: awrap_tool_call (Process 2 — core of diagram)
|
|
567
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
568
|
+
|
|
569
|
+
async def handle_wrap_tool_call(mw: OpenBoxMiddleware, request: Any, handler: Any) -> Any:
|
|
570
|
+
"""Tool governance: ToolStarted → Tool (OTel spans) → ToolCompleted.
|
|
571
|
+
|
|
572
|
+
Wraps each tool execution. Manages SpanProcessor context for OTel span
|
|
573
|
+
capture during tool execution (HTTP/DB/file governance hooks).
|
|
574
|
+
"""
|
|
575
|
+
tool_name = request.tool_call["name"]
|
|
576
|
+
tool_args = request.tool_call.get("args", {})
|
|
577
|
+
|
|
578
|
+
# 1. Skip if in skip_tool_types
|
|
579
|
+
if tool_name in (mw._config.skip_tool_types or set()):
|
|
580
|
+
return await handler(request)
|
|
581
|
+
|
|
582
|
+
# 2. Detect subagent
|
|
583
|
+
subagent_name = resolve_subagent_from_tool_call(tool_name, tool_args)
|
|
584
|
+
|
|
585
|
+
# 3. Classify tool and build enriched input
|
|
586
|
+
activity_id = str(uuid.uuid4())
|
|
587
|
+
tool_type = mw._resolve_tool_type(tool_name, subagent_name)
|
|
588
|
+
enriched_input = mw._enrich_activity_input(
|
|
589
|
+
[safe_serialize(tool_args)], tool_type, subagent_name
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
base = _base_event_fields(mw)
|
|
593
|
+
|
|
594
|
+
# === BEFORE TOOL CALL ===
|
|
595
|
+
|
|
596
|
+
# 4. Register SpanProcessor context for all tools (including subagents)
|
|
597
|
+
# Subagent internal HTTP/DB/file calls should trigger hook-level governance
|
|
598
|
+
if mw._span_processor:
|
|
599
|
+
activity_context = {
|
|
600
|
+
**base,
|
|
601
|
+
"event_type": "ActivityStarted",
|
|
602
|
+
"activity_id": activity_id,
|
|
603
|
+
"activity_type": tool_name,
|
|
604
|
+
}
|
|
605
|
+
mw._span_processor.set_activity_context(mw._workflow_id, activity_id, activity_context)
|
|
606
|
+
|
|
607
|
+
# 5. Send ToolStarted + enforce verdict
|
|
608
|
+
_logger.debug("[OpenBox] ToolStarted SENDING: tool=%s activity_id=%s tool_type=%s subagent=%s",
|
|
609
|
+
tool_name, activity_id, tool_type, subagent_name)
|
|
610
|
+
if mw._config.send_tool_start_event:
|
|
611
|
+
gov = LangChainGovernanceEvent(
|
|
612
|
+
**base,
|
|
613
|
+
event_type="ToolStarted",
|
|
614
|
+
activity_id=activity_id,
|
|
615
|
+
activity_type=tool_name,
|
|
616
|
+
activity_input=enriched_input,
|
|
617
|
+
tool_name=tool_name,
|
|
618
|
+
tool_type=tool_type,
|
|
619
|
+
tool_input=safe_serialize(tool_args),
|
|
620
|
+
subagent_name=subagent_name,
|
|
621
|
+
)
|
|
622
|
+
response = await _evaluate(mw,gov)
|
|
623
|
+
if response is not None:
|
|
624
|
+
result = enforce_verdict(response, "tool_start")
|
|
625
|
+
if result.requires_hitl:
|
|
626
|
+
try:
|
|
627
|
+
await poll_until_decision(
|
|
628
|
+
mw._client,
|
|
629
|
+
HITLPollParams(
|
|
630
|
+
workflow_id=mw._workflow_id,
|
|
631
|
+
run_id=mw._run_id,
|
|
632
|
+
activity_id=activity_id,
|
|
633
|
+
activity_type=tool_name,
|
|
634
|
+
),
|
|
635
|
+
mw._config.hitl,
|
|
636
|
+
)
|
|
637
|
+
except (ApprovalRejectedError, ApprovalExpiredError) as e:
|
|
638
|
+
# Clear SpanProcessor before raising
|
|
639
|
+
if mw._span_processor:
|
|
640
|
+
mw._span_processor.clear_activity_context(mw._workflow_id, activity_id)
|
|
641
|
+
raise GovernanceHaltError(str(e)) from e
|
|
642
|
+
|
|
643
|
+
# === TOOL CALL (OTel span bridges asyncio.Task boundary) ===
|
|
644
|
+
# Retry loop: if a hook returns REQUIRE_APPROVAL, poll for approval and retry.
|
|
645
|
+
# Loops until the tool succeeds or a non-approval error occurs.
|
|
646
|
+
# poll_until_decision has no deadline — OpenBox server controls expiration.
|
|
647
|
+
|
|
648
|
+
start = time.monotonic()
|
|
649
|
+
while True:
|
|
650
|
+
try:
|
|
651
|
+
tool_result = await _run_with_otel_context(
|
|
652
|
+
mw, f"tool.{tool_name}", activity_id, handler, request,
|
|
653
|
+
)
|
|
654
|
+
break # success — exit retry loop
|
|
655
|
+
except GovernanceBlockedError as hook_err:
|
|
656
|
+
if hook_err.verdict != "require_approval":
|
|
657
|
+
_logger.warning(
|
|
658
|
+
"[OpenBox] Hook BLOCKED tool=%s verdict=%s",
|
|
659
|
+
tool_name, hook_err.verdict,
|
|
660
|
+
)
|
|
661
|
+
duration_ms = (time.monotonic() - start) * 1000
|
|
662
|
+
if mw._span_processor:
|
|
663
|
+
mw._span_processor.clear_activity_context(mw._workflow_id, activity_id)
|
|
664
|
+
if mw._config.send_tool_end_event:
|
|
665
|
+
failed_event = LangChainGovernanceEvent(
|
|
666
|
+
**_base_event_fields(mw),
|
|
667
|
+
event_type="ToolCompleted",
|
|
668
|
+
activity_id=f"{activity_id}-c",
|
|
669
|
+
activity_type=tool_name,
|
|
670
|
+
activity_output=safe_serialize({"error": str(hook_err)}),
|
|
671
|
+
tool_name=tool_name,
|
|
672
|
+
tool_type=tool_type,
|
|
673
|
+
subagent_name=subagent_name,
|
|
674
|
+
status="failed",
|
|
675
|
+
duration_ms=duration_ms,
|
|
676
|
+
)
|
|
677
|
+
await _evaluate(mw, failed_event)
|
|
678
|
+
raise
|
|
679
|
+
|
|
680
|
+
_logger.info("[OpenBox] Hook REQUIRE_APPROVAL during activity=%s, polling", tool_name)
|
|
681
|
+
await _poll_approval_or_halt(mw, activity_id, tool_name)
|
|
682
|
+
_logger.info("[OpenBox] Approval granted, retrying activity=%s", tool_name)
|
|
683
|
+
|
|
684
|
+
except Exception as exc:
|
|
685
|
+
hook_err = _extract_governance_blocked(exc)
|
|
686
|
+
if hook_err is not None and hook_err.verdict == "require_approval":
|
|
687
|
+
_logger.info(
|
|
688
|
+
"[OpenBox] Hook REQUIRE_APPROVAL (wrapped) "
|
|
689
|
+
"during activity=%s, polling", tool_name,
|
|
690
|
+
)
|
|
691
|
+
await _poll_approval_or_halt(mw, activity_id, tool_name)
|
|
692
|
+
_logger.info("[OpenBox] Approval granted, retrying activity=%s", tool_name)
|
|
693
|
+
else:
|
|
694
|
+
_logger.warning(
|
|
695
|
+
"[OpenBox] wrap_tool_call EXCEPTION: "
|
|
696
|
+
"tool=%s activity_id=%s error=%s",
|
|
697
|
+
tool_name, activity_id, exc,
|
|
698
|
+
)
|
|
699
|
+
duration_ms = (time.monotonic() - start) * 1000
|
|
700
|
+
if mw._span_processor:
|
|
701
|
+
mw._span_processor.clear_activity_context(mw._workflow_id, activity_id)
|
|
702
|
+
if mw._config.send_tool_end_event:
|
|
703
|
+
failed_event = LangChainGovernanceEvent(
|
|
704
|
+
**_base_event_fields(mw),
|
|
705
|
+
event_type="ToolCompleted",
|
|
706
|
+
activity_id=f"{activity_id}-c",
|
|
707
|
+
activity_type=tool_name,
|
|
708
|
+
activity_output=safe_serialize({"error": str(exc)}),
|
|
709
|
+
tool_name=tool_name,
|
|
710
|
+
tool_type=tool_type,
|
|
711
|
+
subagent_name=subagent_name,
|
|
712
|
+
status="failed",
|
|
713
|
+
duration_ms=duration_ms,
|
|
714
|
+
)
|
|
715
|
+
await _evaluate(mw, failed_event)
|
|
716
|
+
raise
|
|
717
|
+
duration_ms = (time.monotonic() - start) * 1000
|
|
718
|
+
_logger.debug(
|
|
719
|
+
"[OpenBox] wrap_tool_call AFTER: tool=%s activity_id=%s "
|
|
720
|
+
"duration=%.0fms send_tool_end=%s",
|
|
721
|
+
tool_name, activity_id, duration_ms,
|
|
722
|
+
mw._config.send_tool_end_event,
|
|
723
|
+
)
|
|
724
|
+
|
|
725
|
+
# === AFTER TOOL CALL ===
|
|
726
|
+
|
|
727
|
+
# 6. Clear SpanProcessor context
|
|
728
|
+
if mw._span_processor:
|
|
729
|
+
mw._span_processor.clear_activity_context(mw._workflow_id, activity_id)
|
|
730
|
+
|
|
731
|
+
# 7. Send ToolCompleted + enforce verdict
|
|
732
|
+
_logger.debug(
|
|
733
|
+
"[OpenBox] ToolCompleted PREPARING: tool=%s activity_id=%s-c",
|
|
734
|
+
tool_name, activity_id,
|
|
735
|
+
)
|
|
736
|
+
if mw._config.send_tool_end_event:
|
|
737
|
+
try:
|
|
738
|
+
serialized_output = (
|
|
739
|
+
safe_serialize({"result": tool_result})
|
|
740
|
+
if isinstance(tool_result, str)
|
|
741
|
+
else safe_serialize(tool_result)
|
|
742
|
+
)
|
|
743
|
+
except Exception:
|
|
744
|
+
serialized_output = {"result": str(tool_result)}
|
|
745
|
+
completed = LangChainGovernanceEvent(
|
|
746
|
+
**_base_event_fields(mw),
|
|
747
|
+
event_type="ToolCompleted",
|
|
748
|
+
activity_id=f"{activity_id}-c",
|
|
749
|
+
activity_type=tool_name,
|
|
750
|
+
activity_output=serialized_output,
|
|
751
|
+
tool_name=tool_name,
|
|
752
|
+
tool_type=tool_type,
|
|
753
|
+
subagent_name=subagent_name,
|
|
754
|
+
status="completed",
|
|
755
|
+
duration_ms=duration_ms,
|
|
756
|
+
)
|
|
757
|
+
_logger.debug(
|
|
758
|
+
"[OpenBox] ToolCompleted SENDING: tool=%s activity_id=%s-c",
|
|
759
|
+
tool_name, activity_id,
|
|
760
|
+
)
|
|
761
|
+
resp = await _evaluate(mw, completed)
|
|
762
|
+
_logger.debug(
|
|
763
|
+
"[OpenBox] ToolCompleted SENT: tool=%s activity_id=%s-c resp=%s",
|
|
764
|
+
tool_name, activity_id, resp,
|
|
765
|
+
)
|
|
766
|
+
if resp is not None:
|
|
767
|
+
result = enforce_verdict(resp, "tool_end")
|
|
768
|
+
if result.requires_hitl:
|
|
769
|
+
try:
|
|
770
|
+
await poll_until_decision(
|
|
771
|
+
mw._client,
|
|
772
|
+
HITLPollParams(
|
|
773
|
+
workflow_id=mw._workflow_id,
|
|
774
|
+
run_id=mw._run_id,
|
|
775
|
+
activity_id=f"{activity_id}-c",
|
|
776
|
+
activity_type=tool_name,
|
|
777
|
+
),
|
|
778
|
+
mw._config.hitl,
|
|
779
|
+
)
|
|
780
|
+
except (ApprovalRejectedError, ApprovalExpiredError) as e:
|
|
781
|
+
raise GovernanceHaltError(str(e)) from e
|
|
782
|
+
|
|
783
|
+
return tool_result
|