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,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