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.
- cortexhub/adapters/claude_agents.py +234 -7
- cortexhub/adapters/crewai.py +275 -1
- cortexhub/adapters/langgraph.py +119 -0
- cortexhub/adapters/openai_agents.py +335 -11
- cortexhub/client.py +84 -1
- {cortexhub-0.1.2.dist-info → cortexhub-0.1.4.dist-info}/METADATA +14 -10
- {cortexhub-0.1.2.dist-info → cortexhub-0.1.4.dist-info}/RECORD +9 -8
- {cortexhub-0.1.2.dist-info → cortexhub-0.1.4.dist-info}/WHEEL +1 -1
- cortexhub-0.1.4.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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=
|
|
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,
|
|
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
|
|
cortexhub/adapters/crewai.py
CHANGED
|
@@ -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
|