cortexhub 0.1.2__py3-none-any.whl → 0.1.4__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.
@@ -19,11 +19,13 @@ Architectural rules:
19
19
  - No governance logic in adapter
20
20
  """
21
21
 
22
+ import json
22
23
  import os
23
24
  from functools import wraps
24
25
  from typing import TYPE_CHECKING, Any, Callable, Awaitable
25
26
 
26
27
  import structlog
28
+ from opentelemetry.trace import SpanKind, Status, StatusCode
27
29
 
28
30
  from cortexhub.adapters.base import ToolAdapter
29
31
  from cortexhub.pipeline import govern_execution
@@ -36,6 +38,10 @@ logger = structlog.get_logger(__name__)
36
38
  # Attribute names for storing originals
37
39
  _ORIGINAL_TOOL_ATTR = "__cortexhub_original_tool__"
38
40
  _PATCHED_ATTR = "__cortexhub_patched__"
41
+ _ORIGINAL_QUERY_ATTR = "__cortexhub_original_query__"
42
+ _ORIGINAL_RECEIVE_RESPONSE_ATTR = "__cortexhub_original_receive_response__"
43
+ _ORIGINAL_CLIENT_QUERY_ATTR = "__cortexhub_original_client_query__"
44
+ _PATCHED_RUN_ATTR = "__cortexhub_run_patched__"
39
45
 
40
46
 
41
47
  class ClaudeAgentsAdapter(ToolAdapter):
@@ -64,6 +70,10 @@ class ClaudeAgentsAdapter(ToolAdapter):
64
70
  def framework_name(self) -> str:
65
71
  return "claude_agents"
66
72
 
73
+ def __init__(self, cortex_hub: Any):
74
+ super().__init__(cortex_hub)
75
+ self._hook_spans: dict[str, Any] = {}
76
+
67
77
  def _get_framework_modules(self) -> list[str]:
68
78
  return ["claude_agent_sdk"]
69
79
 
@@ -145,6 +155,8 @@ class ClaudeAgentsAdapter(ToolAdapter):
145
155
  setattr(claude_agent_sdk, _PATCHED_ATTR, True)
146
156
 
147
157
  logger.info("Claude Agent SDK @tool decorator patched successfully")
158
+
159
+ self._patch_run_completion(cortex_hub)
148
160
 
149
161
  except ImportError:
150
162
  logger.debug("Claude Agent SDK not installed, skipping adapter")
@@ -161,6 +173,23 @@ class ClaudeAgentsAdapter(ToolAdapter):
161
173
  claude_agent_sdk.tool = original
162
174
  setattr(claude_agent_sdk, _PATCHED_ATTR, False)
163
175
  logger.info("Claude Agent SDK adapter unpatched")
176
+
177
+ try:
178
+ from claude_agent_sdk import ClaudeSDKClient
179
+
180
+ if hasattr(claude_agent_sdk, _ORIGINAL_QUERY_ATTR):
181
+ claude_agent_sdk.query = getattr(claude_agent_sdk, _ORIGINAL_QUERY_ATTR)
182
+ if hasattr(ClaudeSDKClient, _ORIGINAL_CLIENT_QUERY_ATTR):
183
+ ClaudeSDKClient.query = getattr(
184
+ ClaudeSDKClient, _ORIGINAL_CLIENT_QUERY_ATTR
185
+ )
186
+ if hasattr(ClaudeSDKClient, _ORIGINAL_RECEIVE_RESPONSE_ATTR):
187
+ ClaudeSDKClient.receive_response = getattr(
188
+ ClaudeSDKClient, _ORIGINAL_RECEIVE_RESPONSE_ATTR
189
+ )
190
+ setattr(claude_agent_sdk, _PATCHED_RUN_ATTR, False)
191
+ except ImportError:
192
+ pass
164
193
  except ImportError:
165
194
  pass
166
195
 
@@ -171,6 +200,88 @@ class ClaudeAgentsAdapter(ToolAdapter):
171
200
  def _discover_tools(self) -> list[dict[str, Any]]:
172
201
  """Discover tools from Claude Agent SDK (best-effort)."""
173
202
  return []
203
+
204
+ def _patch_run_completion(self, cortex_hub) -> None:
205
+ """Patch Claude Agent SDK runs to emit run completion."""
206
+ try:
207
+ import claude_agent_sdk
208
+ from claude_agent_sdk import ClaudeSDKClient, ResultMessage
209
+
210
+ if getattr(claude_agent_sdk, _PATCHED_RUN_ATTR, False):
211
+ return
212
+
213
+ if not hasattr(claude_agent_sdk, _ORIGINAL_QUERY_ATTR):
214
+ setattr(claude_agent_sdk, _ORIGINAL_QUERY_ATTR, claude_agent_sdk.query)
215
+ original_query = getattr(claude_agent_sdk, _ORIGINAL_QUERY_ATTR)
216
+
217
+ async def patched_query(*args, **kwargs):
218
+ status = None
219
+ failed = False
220
+ cortex_hub.start_run(framework="claude_agents")
221
+ try:
222
+ async for message in original_query(*args, **kwargs):
223
+ if isinstance(message, ResultMessage):
224
+ status = "failed" if message.is_error else "completed"
225
+ yield message
226
+ except Exception:
227
+ failed = True
228
+ raise
229
+ finally:
230
+ if status is None and failed:
231
+ status = "failed"
232
+ if status:
233
+ cortex_hub.finish_run(framework="claude_agents", status=status)
234
+
235
+ claude_agent_sdk.query = patched_query
236
+
237
+ if not hasattr(ClaudeSDKClient, _ORIGINAL_RECEIVE_RESPONSE_ATTR):
238
+ setattr(
239
+ ClaudeSDKClient,
240
+ _ORIGINAL_RECEIVE_RESPONSE_ATTR,
241
+ ClaudeSDKClient.receive_response,
242
+ )
243
+ original_receive_response = getattr(ClaudeSDKClient, _ORIGINAL_RECEIVE_RESPONSE_ATTR)
244
+ if not hasattr(ClaudeSDKClient, _ORIGINAL_CLIENT_QUERY_ATTR):
245
+ setattr(
246
+ ClaudeSDKClient,
247
+ _ORIGINAL_CLIENT_QUERY_ATTR,
248
+ ClaudeSDKClient.query,
249
+ )
250
+ original_client_query = getattr(ClaudeSDKClient, _ORIGINAL_CLIENT_QUERY_ATTR)
251
+
252
+ async def patched_client_query(self, *args, **kwargs):
253
+ cortex_hub.start_run(framework="claude_agents")
254
+ try:
255
+ return await original_client_query(self, *args, **kwargs)
256
+ except Exception:
257
+ cortex_hub.finish_run(framework="claude_agents", status="failed")
258
+ raise
259
+
260
+ async def patched_receive_response(self, *args, **kwargs):
261
+ status = None
262
+ failed = False
263
+ try:
264
+ async for message in original_receive_response(self, *args, **kwargs):
265
+ if isinstance(message, ResultMessage):
266
+ status = "failed" if message.is_error else "completed"
267
+ yield message
268
+ except Exception:
269
+ failed = True
270
+ raise
271
+ finally:
272
+ if status is None and failed:
273
+ status = "failed"
274
+ if status:
275
+ cortex_hub.finish_run(framework="claude_agents", status=status)
276
+
277
+ ClaudeSDKClient.query = patched_client_query
278
+ ClaudeSDKClient.receive_response = patched_receive_response
279
+ setattr(claude_agent_sdk, _PATCHED_RUN_ATTR, True)
280
+ logger.info("Claude Agent SDK run completion patched successfully")
281
+ except ImportError:
282
+ logger.debug("Claude Agent SDK run completion patch skipped")
283
+ except Exception as e:
284
+ logger.debug("Claude Agent SDK run completion patch failed", reason=str(e))
174
285
 
175
286
  def create_governance_hooks(self) -> dict[str, list]:
176
287
  """Create CortexHub governance hooks for Claude Agent SDK.
@@ -191,6 +302,61 @@ class ClaudeAgentsAdapter(ToolAdapter):
191
302
  )
192
303
  """
193
304
  cortex_hub = self.cortex_hub
305
+ span_store = self._hook_spans
306
+
307
+ def _start_tool_span(
308
+ *,
309
+ tool_name: str,
310
+ tool_description: str,
311
+ policy_args: dict[str, Any],
312
+ raw_args: dict[str, Any],
313
+ tool_use_id: str | None,
314
+ ):
315
+ span = cortex_hub._tracer.start_span(
316
+ name="tool.invoke",
317
+ kind=SpanKind.INTERNAL,
318
+ )
319
+ span.set_attribute("cortexhub.session.id", cortex_hub.session_id)
320
+ span.set_attribute("cortexhub.agent.id", cortex_hub.agent_id)
321
+ span.set_attribute("cortexhub.tool.name", tool_name)
322
+ span.set_attribute("cortexhub.tool.framework", "claude_agents")
323
+ span.set_attribute("cortexhub.tool.description", tool_description)
324
+
325
+ if tool_use_id:
326
+ span.set_attribute("cortexhub.tool.use_id", tool_use_id)
327
+
328
+ if policy_args:
329
+ arg_names = list(policy_args.keys())
330
+ if arg_names:
331
+ span.set_attribute("cortexhub.tool.arg_names", arg_names)
332
+ arg_schema = cortex_hub._infer_arg_schema(policy_args)
333
+ if arg_schema:
334
+ span.set_attribute(
335
+ "cortexhub.tool.arg_schema",
336
+ json.dumps(arg_schema),
337
+ )
338
+
339
+ if not cortex_hub.privacy and raw_args:
340
+ span.set_attribute("cortexhub.raw.args", json.dumps(raw_args, default=str))
341
+
342
+ return span
343
+
344
+ def _finish_tool_span(
345
+ span,
346
+ *,
347
+ success: bool,
348
+ error_message: str | None = None,
349
+ result: Any | None = None,
350
+ ) -> None:
351
+ span.set_attribute("cortexhub.result.success", success)
352
+ if error_message:
353
+ span.set_attribute("cortexhub.error.message", error_message)
354
+ span.set_status(Status(StatusCode.ERROR, error_message))
355
+ else:
356
+ span.set_status(Status(StatusCode.OK))
357
+ if result is not None and not cortex_hub.privacy:
358
+ span.set_attribute("cortexhub.raw.result", json.dumps(result, default=str))
359
+ span.end()
194
360
 
195
361
  async def pre_tool_governance(
196
362
  input_data: dict[str, Any],
@@ -204,12 +370,18 @@ class ClaudeAgentsAdapter(ToolAdapter):
204
370
  """
205
371
  tool_name = input_data.get("tool_name", "unknown")
206
372
  tool_input = input_data.get("tool_input", {})
373
+ if not isinstance(tool_input, dict):
374
+ tool_input = {"_raw": tool_input}
375
+ policy_args = cortex_hub._sanitize_policy_args(tool_input)
207
376
 
208
- tool_metadata = {
209
- "name": tool_name,
210
- "description": f"Claude Agent SDK built-in tool: {tool_name}",
211
- "framework": "claude_agents",
212
- }
377
+ tool_description = f"Claude Agent SDK built-in tool: {tool_name}"
378
+ span = _start_tool_span(
379
+ tool_name=tool_name,
380
+ tool_description=tool_description,
381
+ policy_args=policy_args,
382
+ raw_args=tool_input,
383
+ tool_use_id=tool_use_id,
384
+ )
213
385
 
214
386
  # Build authorization request and evaluate
215
387
  from cortexhub.policy.models import (
@@ -222,7 +394,7 @@ class ClaudeAgentsAdapter(ToolAdapter):
222
394
  principal=Principal(type="Agent", id=cortex_hub.agent_id),
223
395
  action=Action(type="tool.invoke", name=tool_name),
224
396
  resource=PolicyResource(type="Tool", id=tool_name),
225
- args=tool_input,
397
+ args=policy_args,
226
398
  framework="claude_agents",
227
399
  )
228
400
 
@@ -230,8 +402,23 @@ class ClaudeAgentsAdapter(ToolAdapter):
230
402
  if cortex_hub.enforce and cortex_hub.evaluator:
231
403
  from cortexhub.policy.effects import Effect
232
404
  decision = cortex_hub.evaluator.evaluate(request)
405
+
406
+ span.add_event(
407
+ "policy.decision",
408
+ attributes={
409
+ "decision.effect": decision.effect.value,
410
+ "decision.policy_id": decision.policy_id or "",
411
+ "decision.reasoning": decision.reasoning,
412
+ "decision.policy_name": decision.policy_name or "",
413
+ },
414
+ )
233
415
 
234
416
  if decision.effect == Effect.DENY:
417
+ _finish_tool_span(
418
+ span,
419
+ success=False,
420
+ error_message=decision.reasoning,
421
+ )
235
422
  return {
236
423
  "hookSpecificOutput": {
237
424
  "hookEventName": "PreToolUse",
@@ -242,7 +429,7 @@ class ClaudeAgentsAdapter(ToolAdapter):
242
429
 
243
430
  if decision.effect == Effect.ESCALATE:
244
431
  try:
245
- context_hash = cortex_hub._compute_context_hash(tool_name, tool_input)
432
+ context_hash = cortex_hub._compute_context_hash(tool_name, policy_args)
246
433
  approval_response = cortex_hub.backend.create_approval(
247
434
  run_id=cortex_hub.session_id,
248
435
  trace_id=cortex_hub._get_current_trace_id(),
@@ -260,6 +447,11 @@ class ClaudeAgentsAdapter(ToolAdapter):
260
447
  approval_id = approval_response.get("approval_id", "unknown")
261
448
  except Exception as e:
262
449
  logger.error("Failed to create approval", error=str(e))
450
+ _finish_tool_span(
451
+ span,
452
+ success=False,
453
+ error_message=str(e),
454
+ )
263
455
  return {
264
456
  "hookSpecificOutput": {
265
457
  "hookEventName": "PreToolUse",
@@ -270,6 +462,20 @@ class ClaudeAgentsAdapter(ToolAdapter):
270
462
  }
271
463
  }
272
464
 
465
+ span.add_event(
466
+ "approval.created",
467
+ attributes={
468
+ "approval_id": approval_id,
469
+ "tool_name": tool_name,
470
+ "policy_id": decision.policy_id or "",
471
+ "expires_at": approval_response.get("expires_at", ""),
472
+ },
473
+ )
474
+ _finish_tool_span(
475
+ span,
476
+ success=False,
477
+ error_message="Approval required",
478
+ )
273
479
  return {
274
480
  "hookSpecificOutput": {
275
481
  "hookEventName": "PreToolUse",
@@ -281,6 +487,10 @@ class ClaudeAgentsAdapter(ToolAdapter):
281
487
  }
282
488
 
283
489
  # Allow execution
490
+ if tool_use_id:
491
+ span_store[tool_use_id] = span
492
+ else:
493
+ span.end()
284
494
  return {}
285
495
 
286
496
  async def post_tool_governance(
@@ -294,6 +504,21 @@ class ClaudeAgentsAdapter(ToolAdapter):
294
504
  """
295
505
  tool_name = input_data.get("tool_name", "unknown")
296
506
  tool_response = input_data.get("tool_response", {})
507
+ tool_input = input_data.get("tool_input", {})
508
+ if not isinstance(tool_input, dict):
509
+ tool_input = {"_raw": tool_input}
510
+ policy_args = cortex_hub._sanitize_policy_args(tool_input)
511
+
512
+ span = span_store.pop(tool_use_id, None) if tool_use_id else None
513
+ if span is None:
514
+ tool_description = f"Claude Agent SDK built-in tool: {tool_name}"
515
+ span = _start_tool_span(
516
+ tool_name=tool_name,
517
+ tool_description=tool_description,
518
+ policy_args=policy_args,
519
+ raw_args=tool_input,
520
+ tool_use_id=tool_use_id,
521
+ )
297
522
 
298
523
  # Log the tool execution
299
524
  logger.debug(
@@ -301,6 +526,8 @@ class ClaudeAgentsAdapter(ToolAdapter):
301
526
  tool=tool_name,
302
527
  framework="claude_agents",
303
528
  )
529
+
530
+ _finish_tool_span(span, success=True, result=tool_response)
304
531
 
305
532
  return {}
306
533
 
@@ -28,11 +28,21 @@ logger = structlog.get_logger(__name__)
28
28
  # Attribute names for storing originals on class
29
29
  _ORIGINAL_INVOKE_ATTR = "__cortexhub_original_invoke__"
30
30
  _ORIGINAL_RUN_ATTR = "__cortexhub_original_run__"
31
+ _ORIGINAL_RUN_METHOD_ATTR = "__cortexhub_original_run_method__"
31
32
  _PATCHED_ATTR = "__cortexhub_patched__"
32
33
  _PATCHED_TOOL_ATTR = "__cortexhub_tool_patched__"
34
+ _PATCHED_RUN_METHOD_ATTR = "__cortexhub_run_method_patched__"
35
+ _ORIGINAL_TOOL_RUN_ATTR = "__cortexhub_original_tool_run__"
36
+ _PATCHED_TOOL_RUN_ATTR = "__cortexhub_tool_run_patched__"
33
37
  _PATCHED_LLM_ATTR = "__cortexhub_llm_patched__"
34
38
  _ORIGINAL_COMPLETION_ATTR = "__cortexhub_original_completion__"
35
39
  _ORIGINAL_ACOMPLETION_ATTR = "__cortexhub_original_acompletion__"
40
+ _ORIGINAL_KICKOFF_ATTR = "__cortexhub_original_kickoff__"
41
+ _ORIGINAL_KICKOFF_ASYNC_ATTR = "__cortexhub_original_kickoff_async__"
42
+ _PATCHED_RUN_ATTR = "__cortexhub_run_patched__"
43
+ _ORIGINAL_LLM_CALL_ATTR = "__cortexhub_original_llm_call__"
44
+ _ORIGINAL_LLM_ACALL_ATTR = "__cortexhub_original_llm_acall__"
45
+ _PATCHED_LLM_CALL_ATTR = "__cortexhub_llm_call_patched__"
36
46
 
37
47
 
38
48
  class CrewAIAdapter(ToolAdapter):
@@ -105,7 +115,7 @@ class CrewAIAdapter(ToolAdapter):
105
115
 
106
116
  # Also patch BaseTool._run for direct tool.run() calls
107
117
  try:
108
- from crewai.tools.base_tool import BaseTool
118
+ from crewai.tools.base_tool import BaseTool, Tool
109
119
 
110
120
  if not getattr(BaseTool, _PATCHED_TOOL_ATTR, False):
111
121
  if not hasattr(BaseTool, _ORIGINAL_RUN_ATTR):
@@ -142,6 +152,76 @@ class CrewAIAdapter(ToolAdapter):
142
152
  BaseTool._run = patched_run
143
153
  setattr(BaseTool, _PATCHED_TOOL_ATTR, True)
144
154
  logger.info("CrewAI BaseTool._run patched")
155
+
156
+ if not getattr(BaseTool, _PATCHED_RUN_METHOD_ATTR, False):
157
+ if not hasattr(BaseTool, _ORIGINAL_RUN_METHOD_ATTR):
158
+ setattr(BaseTool, _ORIGINAL_RUN_METHOD_ATTR, BaseTool.run)
159
+
160
+ original_run_method = getattr(BaseTool, _ORIGINAL_RUN_METHOD_ATTR)
161
+
162
+ def patched_run_method(self, *args, **kwargs):
163
+ """Governed BaseTool.run execution."""
164
+ tool_name = getattr(self, "name", "unknown_tool")
165
+ tool_description = getattr(self, "description", None)
166
+
167
+ tool_metadata = {
168
+ "name": tool_name,
169
+ "description": tool_description,
170
+ "framework": "crewai",
171
+ }
172
+
173
+ governed_fn = govern_execution(
174
+ tool_fn=lambda **_kw: original_run_method(self, *args, **kwargs),
175
+ tool_metadata=tool_metadata,
176
+ cortex_hub=cortex_hub,
177
+ )
178
+
179
+ if kwargs:
180
+ return governed_fn(**kwargs)
181
+ if len(args) == 1 and isinstance(args[0], dict):
182
+ return governed_fn(**args[0])
183
+ if args:
184
+ return governed_fn(_raw=args[0])
185
+ return governed_fn()
186
+
187
+ BaseTool.run = patched_run_method
188
+ setattr(BaseTool, _PATCHED_RUN_METHOD_ATTR, True)
189
+ logger.info("CrewAI BaseTool.run patched")
190
+
191
+ if not getattr(Tool, _PATCHED_TOOL_RUN_ATTR, False):
192
+ if not hasattr(Tool, _ORIGINAL_TOOL_RUN_ATTR):
193
+ setattr(Tool, _ORIGINAL_TOOL_RUN_ATTR, Tool.run)
194
+
195
+ original_tool_run = getattr(Tool, _ORIGINAL_TOOL_RUN_ATTR)
196
+
197
+ def patched_tool_run(self, *args, **kwargs):
198
+ """Governed Tool.run execution (bypasses BaseTool.run)."""
199
+ tool_name = getattr(self, "name", "unknown_tool")
200
+ tool_description = getattr(self, "description", None)
201
+
202
+ tool_metadata = {
203
+ "name": tool_name,
204
+ "description": tool_description,
205
+ "framework": "crewai",
206
+ }
207
+
208
+ governed_fn = govern_execution(
209
+ tool_fn=lambda **_kw: original_tool_run(self, *args, **kwargs),
210
+ tool_metadata=tool_metadata,
211
+ cortex_hub=cortex_hub,
212
+ )
213
+
214
+ if kwargs:
215
+ return governed_fn(**kwargs)
216
+ if len(args) == 1 and isinstance(args[0], dict):
217
+ return governed_fn(**args[0])
218
+ if args:
219
+ return governed_fn(_raw=args[0])
220
+ return governed_fn()
221
+
222
+ Tool.run = patched_tool_run
223
+ setattr(Tool, _PATCHED_TOOL_RUN_ATTR, True)
224
+ logger.info("CrewAI Tool.run patched")
145
225
 
146
226
  except ImportError:
147
227
  logger.debug("CrewAI BaseTool not available")
@@ -150,6 +230,8 @@ class CrewAIAdapter(ToolAdapter):
150
230
 
151
231
  # Patch LiteLLM for LLM call governance (guardrails, PII)
152
232
  self._patch_litellm(cortex_hub)
233
+ self._patch_llm_call(cortex_hub)
234
+ self._patch_run_completion(cortex_hub)
153
235
 
154
236
  except ImportError:
155
237
  logger.debug("CrewAI not available, skipping adapter")
@@ -251,6 +333,183 @@ class CrewAIAdapter(ToolAdapter):
251
333
  except Exception as e:
252
334
  logger.debug("CrewAI LiteLLM interception skipped", reason=str(e))
253
335
 
336
+ def _patch_llm_call(self, cortex_hub) -> None:
337
+ """Patch CrewAI LLM provider call methods for llm.call spans."""
338
+ try:
339
+ from crewai.llms.base_llm import BaseLLM
340
+ provider_modules = [
341
+ "crewai.llms.providers.openai.completion",
342
+ "crewai.llms.providers.anthropic.completion",
343
+ "crewai.llms.providers.azure.completion",
344
+ "crewai.llms.providers.gemini.completion",
345
+ "crewai.llms.providers.bedrock.completion",
346
+ ]
347
+ for module_path in provider_modules:
348
+ try:
349
+ __import__(module_path)
350
+ except Exception:
351
+ continue
352
+
353
+ def _all_subclasses(base):
354
+ seen = set()
355
+ stack = list(base.__subclasses__())
356
+ while stack:
357
+ cls = stack.pop()
358
+ if cls in seen:
359
+ continue
360
+ seen.add(cls)
361
+ stack.extend(cls.__subclasses__())
362
+ yield cls
363
+
364
+ patched_any = False
365
+ for llm_cls in _all_subclasses(BaseLLM):
366
+ if getattr(llm_cls, _PATCHED_LLM_CALL_ATTR, False):
367
+ continue
368
+
369
+ if hasattr(llm_cls, "call"):
370
+ if not hasattr(llm_cls, _ORIGINAL_LLM_CALL_ATTR):
371
+ setattr(llm_cls, _ORIGINAL_LLM_CALL_ATTR, llm_cls.call)
372
+ original_call = getattr(llm_cls, _ORIGINAL_LLM_CALL_ATTR)
373
+
374
+ def _make_patched_call(call_impl):
375
+ def patched_call(self, messages, *args, **kwargs):
376
+ if getattr(self, "is_litellm", False):
377
+ return call_impl(self, messages, *args, **kwargs)
378
+
379
+ model = getattr(self, "model", "unknown")
380
+ prompt = messages
381
+
382
+ def call_original(prompt_override):
383
+ new_messages = (
384
+ prompt_override if prompt_override is not None else messages
385
+ )
386
+ return call_impl(self, new_messages, *args, **kwargs)
387
+
388
+ llm_metadata = {
389
+ "kind": "llm",
390
+ "framework": "crewai",
391
+ "model": model,
392
+ "prompt": prompt,
393
+ "call_original": call_original,
394
+ }
395
+
396
+ governed = govern_execution(
397
+ tool_fn=lambda *a, **kw: call_impl(
398
+ self, messages, *args, **kwargs
399
+ ),
400
+ tool_metadata=llm_metadata,
401
+ cortex_hub=cortex_hub,
402
+ )
403
+ return governed()
404
+
405
+ return patched_call
406
+
407
+ llm_cls.call = _make_patched_call(original_call)
408
+ patched_any = True
409
+
410
+ if hasattr(llm_cls, "acall"):
411
+ if not hasattr(llm_cls, _ORIGINAL_LLM_ACALL_ATTR):
412
+ setattr(llm_cls, _ORIGINAL_LLM_ACALL_ATTR, llm_cls.acall)
413
+ original_acall = getattr(llm_cls, _ORIGINAL_LLM_ACALL_ATTR)
414
+
415
+ def _make_patched_acall(call_impl):
416
+ async def patched_acall(self, messages, *args, **kwargs):
417
+ if getattr(self, "is_litellm", False):
418
+ return await call_impl(self, messages, *args, **kwargs)
419
+
420
+ model = getattr(self, "model", "unknown")
421
+ prompt = messages
422
+
423
+ async def call_original(prompt_override):
424
+ new_messages = (
425
+ prompt_override if prompt_override is not None else messages
426
+ )
427
+ return await call_impl(self, new_messages, *args, **kwargs)
428
+
429
+ llm_metadata = {
430
+ "kind": "llm",
431
+ "framework": "crewai",
432
+ "model": model,
433
+ "prompt": prompt,
434
+ "call_original": call_original,
435
+ }
436
+
437
+ governed = govern_execution(
438
+ tool_fn=lambda *a, **kw: call_impl(
439
+ self, messages, *args, **kwargs
440
+ ),
441
+ tool_metadata=llm_metadata,
442
+ cortex_hub=cortex_hub,
443
+ )
444
+ return await governed()
445
+
446
+ return patched_acall
447
+
448
+ llm_cls.acall = _make_patched_acall(original_acall)
449
+ patched_any = True
450
+
451
+ setattr(llm_cls, _PATCHED_LLM_CALL_ATTR, True)
452
+
453
+ if patched_any:
454
+ logger.info("CrewAI LLM provider calls patched successfully")
455
+ except ImportError:
456
+ logger.debug("CrewAI LLM base not available, skipping LLM call patch")
457
+ except Exception as e:
458
+ logger.debug("CrewAI LLM call patch skipped", reason=str(e))
459
+
460
+ def _patch_run_completion(self, cortex_hub) -> None:
461
+ """Patch CrewAI crew kickoff methods to emit run completion."""
462
+ try:
463
+ import crewai
464
+ Crew = getattr(crewai, "Crew", None)
465
+ if Crew is None:
466
+ from crewai.crew import Crew
467
+
468
+ if getattr(Crew, _PATCHED_RUN_ATTR, False):
469
+ return
470
+
471
+ if not hasattr(Crew, _ORIGINAL_KICKOFF_ATTR):
472
+ setattr(Crew, _ORIGINAL_KICKOFF_ATTR, Crew.kickoff)
473
+ original_kickoff = getattr(Crew, _ORIGINAL_KICKOFF_ATTR)
474
+
475
+ def patched_kickoff(self, *args, **kwargs):
476
+ status = "completed"
477
+ cortex_hub.start_run(framework="crewai")
478
+ try:
479
+ return original_kickoff(self, *args, **kwargs)
480
+ except Exception:
481
+ status = "failed"
482
+ raise
483
+ finally:
484
+ cortex_hub.finish_run(framework="crewai", status=status)
485
+
486
+ Crew.kickoff = patched_kickoff
487
+
488
+ if hasattr(Crew, "kickoff_async"):
489
+ if not hasattr(Crew, _ORIGINAL_KICKOFF_ASYNC_ATTR):
490
+ setattr(Crew, _ORIGINAL_KICKOFF_ASYNC_ATTR, Crew.kickoff_async)
491
+ original_kickoff_async = getattr(Crew, _ORIGINAL_KICKOFF_ASYNC_ATTR)
492
+
493
+ async def patched_kickoff_async(self, *args, **kwargs):
494
+ status = "completed"
495
+ cortex_hub.start_run(framework="crewai")
496
+ try:
497
+ return await original_kickoff_async(self, *args, **kwargs)
498
+ except Exception:
499
+ status = "failed"
500
+ raise
501
+ finally:
502
+ cortex_hub.finish_run(framework="crewai", status=status)
503
+
504
+ Crew.kickoff_async = patched_kickoff_async
505
+
506
+ setattr(Crew, _PATCHED_RUN_ATTR, True)
507
+ logger.info("CrewAI run completion patched successfully")
508
+ except ImportError:
509
+ logger.debug("CrewAI run completion patch skipped")
510
+ except Exception as e:
511
+ logger.debug("CrewAI run completion patch failed", reason=str(e))
512
+
254
513
  def unpatch(self) -> None:
255
514
  """Restore original CrewAI methods."""
256
515
  try:
@@ -284,6 +543,21 @@ class CrewAIAdapter(ToolAdapter):
284
543
  logger.info("CrewAI LiteLLM unpatched")
285
544
  except ImportError:
286
545
  pass
546
+
547
+ # Restore run completion patches
548
+ try:
549
+ import crewai
550
+ Crew = getattr(crewai, "Crew", None)
551
+ if Crew is None:
552
+ from crewai.crew import Crew
553
+
554
+ if hasattr(Crew, _ORIGINAL_KICKOFF_ATTR):
555
+ Crew.kickoff = getattr(Crew, _ORIGINAL_KICKOFF_ATTR)
556
+ if hasattr(Crew, _ORIGINAL_KICKOFF_ASYNC_ATTR):
557
+ Crew.kickoff_async = getattr(Crew, _ORIGINAL_KICKOFF_ASYNC_ATTR)
558
+ setattr(Crew, _PATCHED_RUN_ATTR, False)
559
+ except ImportError:
560
+ pass
287
561
 
288
562
  except ImportError:
289
563
  pass