agentx-python 0.4.10__py3-none-any.whl → 0.4.12__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.
- agentx/__init__.py +19 -1
- agentx/agentx.py +9 -0
- agentx/evaluations/client.py +14 -0
- agentx/evaluations/datasets.py +28 -0
- agentx/evaluations/models.py +80 -0
- agentx/evaluations/runner.py +33 -11
- agentx/exceptions.py +46 -0
- agentx/integrations/__init__.py +7 -0
- agentx/integrations/anthropic.py +152 -0
- agentx/integrations/crewai.py +106 -0
- agentx/integrations/langchain.py +249 -0
- agentx/integrations/openai_agents.py +125 -0
- agentx/resources/conversation.py +4 -4
- agentx/tracing/__init__.py +21 -0
- agentx/tracing/ci_types.py +66 -0
- agentx/tracing/ingest_client.py +312 -0
- agentx/tracing/tracer.py +465 -0
- agentx/version.py +1 -1
- {agentx_python-0.4.10.dist-info → agentx_python-0.4.12.dist-info}/METADATA +15 -1
- agentx_python-0.4.12.dist-info/RECORD +37 -0
- agentx_python-0.4.10.dist-info/RECORD +0 -27
- {agentx_python-0.4.10.dist-info → agentx_python-0.4.12.dist-info}/WHEEL +0 -0
- {agentx_python-0.4.10.dist-info → agentx_python-0.4.12.dist-info}/licenses/LICENSE +0 -0
- {agentx_python-0.4.10.dist-info → agentx_python-0.4.12.dist-info}/top_level.txt +0 -0
agentx/__init__.py
CHANGED
|
@@ -2,6 +2,15 @@ import logging
|
|
|
2
2
|
|
|
3
3
|
from agentx.agentx import AgentX
|
|
4
4
|
from agentx.version import VERSION
|
|
5
|
+
from agentx.exceptions import (
|
|
6
|
+
AgentXError,
|
|
7
|
+
AgentXAuthError,
|
|
8
|
+
AgentXAPIError,
|
|
9
|
+
DatasetNotFound,
|
|
10
|
+
CINotEnabled,
|
|
11
|
+
CIRunExpired,
|
|
12
|
+
CIGateFailure,
|
|
13
|
+
)
|
|
5
14
|
|
|
6
15
|
logging.basicConfig(
|
|
7
16
|
level=logging.INFO,
|
|
@@ -9,5 +18,14 @@ logging.basicConfig(
|
|
|
9
18
|
datefmt="%Y-%m-%d %H:%M:%S %Z",
|
|
10
19
|
)
|
|
11
20
|
|
|
12
|
-
__all__ = [
|
|
21
|
+
__all__ = [
|
|
22
|
+
"AgentX",
|
|
23
|
+
"AgentXError",
|
|
24
|
+
"AgentXAuthError",
|
|
25
|
+
"AgentXAPIError",
|
|
26
|
+
"DatasetNotFound",
|
|
27
|
+
"CINotEnabled",
|
|
28
|
+
"CIRunExpired",
|
|
29
|
+
"CIGateFailure",
|
|
30
|
+
]
|
|
13
31
|
__version__ = VERSION
|
agentx/agentx.py
CHANGED
|
@@ -22,6 +22,8 @@ class AgentX:
|
|
|
22
22
|
|
|
23
23
|
from agentx.evaluations.client import EvaluationsClient
|
|
24
24
|
from agentx.evaluations.runner import EvaluationsRunner
|
|
25
|
+
from agentx.tracing.ingest_client import IngestClient
|
|
26
|
+
from agentx.tracing.tracer import Tracer
|
|
25
27
|
from agentx.version import VERSION
|
|
26
28
|
|
|
27
29
|
_eval_client = EvaluationsClient(
|
|
@@ -31,6 +33,13 @@ class AgentX:
|
|
|
31
33
|
)
|
|
32
34
|
self.evaluations = EvaluationsRunner(_eval_client)
|
|
33
35
|
|
|
36
|
+
_ingest_client = IngestClient(
|
|
37
|
+
api_key=self.api_key,
|
|
38
|
+
sdk_version=VERSION,
|
|
39
|
+
base_url=self.base_url,
|
|
40
|
+
)
|
|
41
|
+
self.tracer = Tracer(_ingest_client)
|
|
42
|
+
|
|
34
43
|
@classmethod
|
|
35
44
|
def from_env(cls) -> "AgentX":
|
|
36
45
|
"""Create an AgentX client using AGENTX_API_KEY (and optionally AGENTX_API_BASE_URL) from the environment."""
|
agentx/evaluations/client.py
CHANGED
|
@@ -14,6 +14,7 @@ from agentx.evaluations.models import (
|
|
|
14
14
|
EvaluationResult,
|
|
15
15
|
EvaluationRun,
|
|
16
16
|
EvaluationSubject,
|
|
17
|
+
ModelInfo,
|
|
17
18
|
Report,
|
|
18
19
|
)
|
|
19
20
|
|
|
@@ -106,6 +107,19 @@ class EvaluationsClient:
|
|
|
106
107
|
return resp.text
|
|
107
108
|
raise AgentXEvaluationsError(f"Request failed after retries: {last_exc}")
|
|
108
109
|
|
|
110
|
+
# ------------------------------------------------------------------
|
|
111
|
+
# Model registry
|
|
112
|
+
# ------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
def list_models(self, provider: Optional[str] = None) -> List[ModelInfo]:
|
|
115
|
+
"""List the LLM models AgentX supports — the same set selectable for
|
|
116
|
+
the Sovereignty & Portability Index. Pass ``provider`` (e.g. "Google")
|
|
117
|
+
to filter."""
|
|
118
|
+
params = {"provider": provider} if provider else None
|
|
119
|
+
data = self._request("GET", "/models", params=params)
|
|
120
|
+
items = data if isinstance(data, list) else data.get("models", [])
|
|
121
|
+
return [ModelInfo(**m) for m in items]
|
|
122
|
+
|
|
109
123
|
# ------------------------------------------------------------------
|
|
110
124
|
# Dataset endpoints
|
|
111
125
|
# ------------------------------------------------------------------
|
agentx/evaluations/datasets.py
CHANGED
|
@@ -27,6 +27,10 @@ class DatasetBuilder:
|
|
|
27
27
|
acceptance_criteria: Optional[str] = None,
|
|
28
28
|
rejection_criteria: Optional[str] = None,
|
|
29
29
|
evaluation_criteria: Optional[str] = None,
|
|
30
|
+
vector_similarity: bool = False,
|
|
31
|
+
jaccard_similarity: bool = False,
|
|
32
|
+
similarity_model: Optional[str] = None,
|
|
33
|
+
sovereignty_models: Optional[List[str]] = None,
|
|
30
34
|
):
|
|
31
35
|
self._client = client
|
|
32
36
|
self._payload: Dict[str, Any] = {
|
|
@@ -38,6 +42,22 @@ class DatasetBuilder:
|
|
|
38
42
|
"evaluationCriteria": evaluation_criteria,
|
|
39
43
|
"questions": [],
|
|
40
44
|
}
|
|
45
|
+
# Opt-in similarity metrics, surfaced on the report as cosine_similarity /
|
|
46
|
+
# jaccard_similarity (computed against each case's expected_results).
|
|
47
|
+
if vector_similarity:
|
|
48
|
+
vs: Dict[str, Any] = {"enabled": True}
|
|
49
|
+
if similarity_model:
|
|
50
|
+
vs["model"] = similarity_model
|
|
51
|
+
self._payload["vectorSimilarity"] = vs
|
|
52
|
+
if jaccard_similarity:
|
|
53
|
+
self._payload["jaccardSimilarity"] = {"enabled": True}
|
|
54
|
+
# Sovereignty & Portability — the models to compare on this dataset (use
|
|
55
|
+
# client.evaluations.list_models() to discover valid ids).
|
|
56
|
+
if sovereignty_models:
|
|
57
|
+
self._payload["sovereigntyIndex"] = {
|
|
58
|
+
"enabled": True,
|
|
59
|
+
"models": list(sovereignty_models),
|
|
60
|
+
}
|
|
41
61
|
|
|
42
62
|
def add_case(
|
|
43
63
|
self,
|
|
@@ -190,6 +210,10 @@ class DatasetClient:
|
|
|
190
210
|
acceptance_criteria: Optional[str] = None,
|
|
191
211
|
rejection_criteria: Optional[str] = None,
|
|
192
212
|
evaluation_criteria: Optional[str] = None,
|
|
213
|
+
vector_similarity: bool = False,
|
|
214
|
+
jaccard_similarity: bool = False,
|
|
215
|
+
similarity_model: Optional[str] = None,
|
|
216
|
+
sovereignty_models: Optional[List[str]] = None,
|
|
193
217
|
) -> DatasetBuilder:
|
|
194
218
|
return DatasetBuilder(
|
|
195
219
|
self._client,
|
|
@@ -199,6 +223,10 @@ class DatasetClient:
|
|
|
199
223
|
acceptance_criteria=acceptance_criteria,
|
|
200
224
|
rejection_criteria=rejection_criteria,
|
|
201
225
|
evaluation_criteria=evaluation_criteria,
|
|
226
|
+
vector_similarity=vector_similarity,
|
|
227
|
+
jaccard_similarity=jaccard_similarity,
|
|
228
|
+
similarity_model=similarity_model,
|
|
229
|
+
sovereignty_models=sovereignty_models,
|
|
202
230
|
)
|
|
203
231
|
|
|
204
232
|
def from_csv(self, path: str, name: str, **kwargs) -> DatasetBuilder:
|
agentx/evaluations/models.py
CHANGED
|
@@ -58,6 +58,18 @@ class Dataset(BaseModel):
|
|
|
58
58
|
questions: List[DatasetQuestion] = Field(default_factory=list)
|
|
59
59
|
status: str = "published"
|
|
60
60
|
version_id: Optional[str] = Field(default=None, alias="versionId")
|
|
61
|
+
# Sovereignty & Portability — models selected to compare on this dataset.
|
|
62
|
+
# Hoisted from the nested ``sovereigntyIndex`` object when enabled.
|
|
63
|
+
sovereignty_models: List[str] = Field(default_factory=list)
|
|
64
|
+
|
|
65
|
+
@model_validator(mode="before")
|
|
66
|
+
@classmethod
|
|
67
|
+
def _extract_sovereignty_models(cls, data: Any) -> Any:
|
|
68
|
+
if isinstance(data, dict):
|
|
69
|
+
sov = data.get("sovereigntyIndex") or data.get("sovereignty_index") or {}
|
|
70
|
+
if isinstance(sov, dict) and sov.get("enabled") and sov.get("models"):
|
|
71
|
+
data = {**data, "sovereignty_models": list(sov.get("models") or [])}
|
|
72
|
+
return data
|
|
61
73
|
|
|
62
74
|
class Config:
|
|
63
75
|
populate_by_name = True
|
|
@@ -99,6 +111,30 @@ class EvaluationSubject(BaseModel):
|
|
|
99
111
|
extra = "ignore"
|
|
100
112
|
|
|
101
113
|
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
# Supported models (the AgentX model registry / portability set)
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class ModelInfo(BaseModel):
|
|
120
|
+
"""One entry from the AgentX supported-model registry — the set selectable
|
|
121
|
+
for the Sovereignty & Portability Index. Returned by ``list_models()``."""
|
|
122
|
+
|
|
123
|
+
name: str
|
|
124
|
+
display: Optional[str] = None
|
|
125
|
+
provider: Optional[str] = None
|
|
126
|
+
context_window: Optional[int] = Field(default=None, alias="contextWindow")
|
|
127
|
+
max_output_tokens: Optional[int] = Field(default=None, alias="maxOutputTokens")
|
|
128
|
+
input_cost: Optional[float] = Field(default=None, alias="inputCost")
|
|
129
|
+
output_cost: Optional[float] = Field(default=None, alias="outputCost")
|
|
130
|
+
knowledge_cutoff: Optional[str] = Field(default=None, alias="knowledgeCutOff")
|
|
131
|
+
legacy: Optional[bool] = None
|
|
132
|
+
|
|
133
|
+
class Config:
|
|
134
|
+
populate_by_name = True
|
|
135
|
+
extra = "ignore"
|
|
136
|
+
|
|
137
|
+
|
|
102
138
|
# ---------------------------------------------------------------------------
|
|
103
139
|
# Run
|
|
104
140
|
# ---------------------------------------------------------------------------
|
|
@@ -144,6 +180,10 @@ class EvaluationCase(BaseModel):
|
|
|
144
180
|
expected_capabilities: Optional[List[str]] = None
|
|
145
181
|
expected_knowledge_base: Optional[List[str]] = None
|
|
146
182
|
expected_delegations: Optional[List[str]] = None
|
|
183
|
+
# Sovereignty & Portability: the model this case should run on. Set when the
|
|
184
|
+
# dataset selects comparison models; your callable can read it to pick the
|
|
185
|
+
# model. The SDK also tags the submitted result with it.
|
|
186
|
+
model: Optional[str] = None
|
|
147
187
|
|
|
148
188
|
class Config:
|
|
149
189
|
extra = "ignore"
|
|
@@ -299,6 +339,43 @@ class ReportRecommendation(BaseModel):
|
|
|
299
339
|
extra = "ignore"
|
|
300
340
|
|
|
301
341
|
|
|
342
|
+
class SovereigntyModelMetrics(BaseModel):
|
|
343
|
+
"""Per-model row of the Sovereignty & Portability matrix."""
|
|
344
|
+
|
|
345
|
+
model: str
|
|
346
|
+
provider: Optional[str] = None
|
|
347
|
+
is_baseline: bool = Field(default=False, alias="isBaseline")
|
|
348
|
+
run_count: int = Field(default=0, alias="runCount")
|
|
349
|
+
average_rating: Optional[float] = Field(default=None, alias="averageRating")
|
|
350
|
+
min_rating: Optional[float] = Field(default=None, alias="minRating")
|
|
351
|
+
max_rating: Optional[float] = Field(default=None, alias="maxRating")
|
|
352
|
+
rating_variance: Optional[float] = Field(default=None, alias="ratingVariance")
|
|
353
|
+
average_vector_similarity: Optional[float] = Field(
|
|
354
|
+
default=None, alias="averageVectorSimilarity"
|
|
355
|
+
)
|
|
356
|
+
average_jaccard_similarity: Optional[float] = Field(
|
|
357
|
+
default=None, alias="averageJaccardSimilarity"
|
|
358
|
+
)
|
|
359
|
+
average_latency_ms: Optional[float] = Field(default=None, alias="averageLatencyMs")
|
|
360
|
+
total_input_tokens: Optional[int] = Field(default=None, alias="totalInputTokens")
|
|
361
|
+
total_output_tokens: Optional[int] = Field(default=None, alias="totalOutputTokens")
|
|
362
|
+
|
|
363
|
+
class Config:
|
|
364
|
+
populate_by_name = True
|
|
365
|
+
extra = "ignore"
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
class SovereigntyIndex(BaseModel):
|
|
369
|
+
"""Sovereignty & Portability matrix — side-by-side per-model performance,
|
|
370
|
+
grouped from the model-tagged results of a run."""
|
|
371
|
+
|
|
372
|
+
enabled: bool = False
|
|
373
|
+
models: List[SovereigntyModelMetrics] = Field(default_factory=list)
|
|
374
|
+
|
|
375
|
+
class Config:
|
|
376
|
+
extra = "ignore"
|
|
377
|
+
|
|
378
|
+
|
|
302
379
|
class Report(BaseModel):
|
|
303
380
|
run_id: str = Field(alias="runId")
|
|
304
381
|
dataset_id: str = Field(alias="datasetId")
|
|
@@ -325,6 +402,9 @@ class Report(BaseModel):
|
|
|
325
402
|
low_scoring_cases: List[Dict[str, Any]] = Field(
|
|
326
403
|
default_factory=list, alias="lowScoringCases"
|
|
327
404
|
)
|
|
405
|
+
sovereignty_index: Optional[SovereigntyIndex] = Field(
|
|
406
|
+
default=None, alias="sovereigntyIndex"
|
|
407
|
+
)
|
|
328
408
|
dashboard_url: Optional[str] = Field(default=None, alias="dashboardUrl")
|
|
329
409
|
|
|
330
410
|
class Config:
|
agentx/evaluations/runner.py
CHANGED
|
@@ -14,6 +14,7 @@ from agentx.evaluations.models import (
|
|
|
14
14
|
EvaluationResult,
|
|
15
15
|
EvaluationRun,
|
|
16
16
|
EvaluationSubject,
|
|
17
|
+
ModelInfo,
|
|
17
18
|
Report,
|
|
18
19
|
)
|
|
19
20
|
from agentx.evaluations.redaction import redact_dict
|
|
@@ -108,6 +109,12 @@ class EvaluationRunContext:
|
|
|
108
109
|
|
|
109
110
|
result = normalized(case)
|
|
110
111
|
result.idempotency_key = idem_key
|
|
112
|
+
# Tag the result with the case's model so the server can group it into
|
|
113
|
+
# the Sovereignty & Portability matrix (the callable may also set it).
|
|
114
|
+
if case.model:
|
|
115
|
+
meta = dict(result.metadata or {})
|
|
116
|
+
meta.setdefault("model", case.model)
|
|
117
|
+
result.metadata = meta
|
|
111
118
|
result = EvaluationResult(
|
|
112
119
|
**{**result.model_dump(), "idempotencyKey": idem_key}
|
|
113
120
|
)
|
|
@@ -217,6 +224,13 @@ class EvaluationsRunner:
|
|
|
217
224
|
self._client = client
|
|
218
225
|
self.datasets = client.datasets
|
|
219
226
|
|
|
227
|
+
def list_models(self, provider: Optional[str] = None) -> List[ModelInfo]:
|
|
228
|
+
"""List the LLM models AgentX supports — the same set selectable for
|
|
229
|
+
the Sovereignty & Portability Index. Pass ``provider`` (e.g. "Google")
|
|
230
|
+
to filter. Useful for discovering valid model identifiers to compare
|
|
231
|
+
against."""
|
|
232
|
+
return self._client.list_models(provider)
|
|
233
|
+
|
|
220
234
|
def run(
|
|
221
235
|
self,
|
|
222
236
|
dataset_id: str,
|
|
@@ -257,21 +271,29 @@ def _wrap_adapter(adapter: AdapterLike) -> Callable[[EvaluationCase], Evaluation
|
|
|
257
271
|
def _build_cases(dataset: Dataset) -> List[EvaluationCase]:
|
|
258
272
|
cases: List[EvaluationCase] = []
|
|
259
273
|
n_runs = max(dataset.number_of_requests, 1)
|
|
274
|
+
# Sovereignty & Portability: when the dataset selects comparison models, run
|
|
275
|
+
# every question/run once per model in this single run so the report groups
|
|
276
|
+
# results into a per-model portability matrix (mirrors the native route).
|
|
277
|
+
# ``[None]`` keeps legacy single-model behavior (case.model stays unset).
|
|
278
|
+
models: List[Optional[str]] = list(dataset.sovereignty_models) or [None]
|
|
260
279
|
for q_idx, question in enumerate(dataset.questions):
|
|
261
280
|
mq = question.main_question
|
|
262
281
|
for run_num in range(1, n_runs + 1):
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
282
|
+
for model in models:
|
|
283
|
+
suffix = f"::{model}" if model else ""
|
|
284
|
+
cases.append(
|
|
285
|
+
EvaluationCase(
|
|
286
|
+
case_id=f"case-{q_idx}{suffix}",
|
|
287
|
+
question_index=q_idx,
|
|
288
|
+
run_number=run_num,
|
|
289
|
+
query=mq.query,
|
|
290
|
+
expected_results=mq.expected_results,
|
|
291
|
+
expected_capabilities=mq.expected_capabilities,
|
|
292
|
+
expected_knowledge_base=mq.expected_knowledge_base,
|
|
293
|
+
expected_delegations=mq.expected_delegations,
|
|
294
|
+
model=model,
|
|
295
|
+
)
|
|
273
296
|
)
|
|
274
|
-
)
|
|
275
297
|
return cases
|
|
276
298
|
|
|
277
299
|
|
agentx/exceptions.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""AgentX SDK exceptions."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from agentx.tracing.ci_types import CIRunResult
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AgentXError(Exception):
|
|
11
|
+
"""Base class for all AgentX SDK errors."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AgentXAuthError(AgentXError):
|
|
15
|
+
"""Invalid or missing API key."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AgentXAPIError(AgentXError):
|
|
19
|
+
"""Unexpected API error."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, message: str, status_code: int | None = None) -> None:
|
|
22
|
+
super().__init__(message)
|
|
23
|
+
self.status_code = status_code
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class DatasetNotFound(AgentXError):
|
|
27
|
+
"""Dataset ID does not exist or is not accessible from this API key."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CINotEnabled(AgentXError):
|
|
31
|
+
"""Dataset exists but ci.enabled is false. Enable CI in the dataset settings."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class CIRunExpired(AgentXError):
|
|
35
|
+
"""CI run was not finalized within the 2-hour window."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class CIGateFailure(AgentXError):
|
|
39
|
+
"""Gate result is 'fail'. Raised when fail_on_gate=True."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, result: "CIRunResult") -> None:
|
|
42
|
+
super().__init__(
|
|
43
|
+
f"CI gate failed: {result.pass_rate:.0%} passed "
|
|
44
|
+
f"({result.passed_questions}/{result.total_questions} questions)"
|
|
45
|
+
)
|
|
46
|
+
self.result = result
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Framework-specific integrations for production tracing.
|
|
2
|
+
# Each sub-module is lazily importable so the core SDK has no extra dependencies.
|
|
3
|
+
#
|
|
4
|
+
# from agentx.integrations.langchain import AgentXCallbackHandler
|
|
5
|
+
# from agentx.integrations.crewai import AgentXCrewObserver
|
|
6
|
+
# from agentx.integrations.openai_agents import AgentXTracingProcessor
|
|
7
|
+
# from agentx.integrations.anthropic import patch_anthropic_client
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Anthropic SDK integration for AgentX production tracing.
|
|
3
|
+
|
|
4
|
+
Usage::
|
|
5
|
+
|
|
6
|
+
from agentx.integrations.anthropic import patch_anthropic_client
|
|
7
|
+
import anthropic
|
|
8
|
+
|
|
9
|
+
client = anthropic.Anthropic()
|
|
10
|
+
patch_anthropic_client(client, agentx.tracer, name="claude-agent")
|
|
11
|
+
|
|
12
|
+
# All subsequent client.messages.create() calls are now traced automatically.
|
|
13
|
+
|
|
14
|
+
Requires: ``pip install agentx[anthropic]``
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import time
|
|
19
|
+
from typing import Any, Dict, Optional
|
|
20
|
+
|
|
21
|
+
from agentx.tracing.tracer import Tracer, _safe_serialize
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def patch_anthropic_client(
|
|
25
|
+
client: Any,
|
|
26
|
+
tracer: Tracer,
|
|
27
|
+
name: str = "anthropic-agent",
|
|
28
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
29
|
+
session_id: Optional[str] = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
"""
|
|
32
|
+
Monkey-patch ``client.messages.create`` and ``client.messages.stream``
|
|
33
|
+
(if present) to automatically send a trace for every call.
|
|
34
|
+
|
|
35
|
+
The original method is still called and its return value is passed through
|
|
36
|
+
unchanged so nothing in the caller needs to change.
|
|
37
|
+
"""
|
|
38
|
+
messages = getattr(client, "messages", None)
|
|
39
|
+
if messages is None:
|
|
40
|
+
raise ValueError("Provided client does not have a .messages attribute")
|
|
41
|
+
|
|
42
|
+
_patch_create(messages, tracer, name, metadata, session_id)
|
|
43
|
+
|
|
44
|
+
# stream is optional (not present in all versions)
|
|
45
|
+
if hasattr(messages, "stream"):
|
|
46
|
+
_patch_stream(messages, tracer, name, metadata, session_id)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _patch_create(
|
|
50
|
+
messages_resource: Any,
|
|
51
|
+
tracer: Tracer,
|
|
52
|
+
name: str,
|
|
53
|
+
metadata: Optional[Dict[str, Any]],
|
|
54
|
+
session_id: Optional[str],
|
|
55
|
+
) -> None:
|
|
56
|
+
original = messages_resource.create
|
|
57
|
+
if getattr(original, "_agentx_patched", False):
|
|
58
|
+
return # already patched
|
|
59
|
+
|
|
60
|
+
def patched_create(*args, **kwargs):
|
|
61
|
+
start = time.time()
|
|
62
|
+
error: Optional[str] = None
|
|
63
|
+
response = None
|
|
64
|
+
try:
|
|
65
|
+
response = original(*args, **kwargs)
|
|
66
|
+
return response
|
|
67
|
+
except Exception as exc:
|
|
68
|
+
error = str(exc)
|
|
69
|
+
raise
|
|
70
|
+
finally:
|
|
71
|
+
latency_ms = int((time.time() - start) * 1000)
|
|
72
|
+
input_messages = kwargs.get("messages") or (args[0] if args else None)
|
|
73
|
+
model = kwargs.get("model")
|
|
74
|
+
output = None
|
|
75
|
+
if response is not None:
|
|
76
|
+
try:
|
|
77
|
+
output = response.content[0].text if response.content else None
|
|
78
|
+
except Exception:
|
|
79
|
+
output = str(response)[:500]
|
|
80
|
+
tracer._send(
|
|
81
|
+
name=name,
|
|
82
|
+
input=_safe_serialize(input_messages),
|
|
83
|
+
output=output,
|
|
84
|
+
latency_ms=latency_ms,
|
|
85
|
+
error=error,
|
|
86
|
+
framework="anthropic",
|
|
87
|
+
model=model,
|
|
88
|
+
metadata=metadata,
|
|
89
|
+
session_id=session_id,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
patched_create._agentx_patched = True
|
|
93
|
+
messages_resource.create = patched_create
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _patch_stream(
|
|
97
|
+
messages_resource: Any,
|
|
98
|
+
tracer: Tracer,
|
|
99
|
+
name: str,
|
|
100
|
+
metadata: Optional[Dict[str, Any]],
|
|
101
|
+
session_id: Optional[str],
|
|
102
|
+
) -> None:
|
|
103
|
+
original_stream = messages_resource.stream
|
|
104
|
+
if getattr(original_stream, "_agentx_patched", False):
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
def patched_stream(*args, **kwargs):
|
|
108
|
+
start = time.time()
|
|
109
|
+
ctx = original_stream(*args, **kwargs)
|
|
110
|
+
|
|
111
|
+
class _TracedStream:
|
|
112
|
+
"""Thin wrapper that records timing when the stream context exits."""
|
|
113
|
+
|
|
114
|
+
def __enter__(self_inner):
|
|
115
|
+
return ctx.__enter__()
|
|
116
|
+
|
|
117
|
+
def __exit__(self_inner, exc_type, exc_val, tb):
|
|
118
|
+
result = ctx.__exit__(exc_type, exc_val, tb)
|
|
119
|
+
latency_ms = int((time.time() - start) * 1000)
|
|
120
|
+
error = str(exc_val) if exc_val else None
|
|
121
|
+
output = None
|
|
122
|
+
try:
|
|
123
|
+
final = ctx.get_final_message()
|
|
124
|
+
output = final.content[0].text if final.content else None
|
|
125
|
+
except Exception:
|
|
126
|
+
pass
|
|
127
|
+
tracer._send(
|
|
128
|
+
name=name,
|
|
129
|
+
input=_safe_serialize(kwargs.get("messages")),
|
|
130
|
+
output=output,
|
|
131
|
+
latency_ms=latency_ms,
|
|
132
|
+
error=error,
|
|
133
|
+
framework="anthropic",
|
|
134
|
+
model=kwargs.get("model"),
|
|
135
|
+
metadata=metadata,
|
|
136
|
+
session_id=session_id,
|
|
137
|
+
)
|
|
138
|
+
return result
|
|
139
|
+
|
|
140
|
+
def __iter__(self_inner):
|
|
141
|
+
return iter(ctx)
|
|
142
|
+
|
|
143
|
+
def __aiter__(self_inner):
|
|
144
|
+
return aiter(ctx)
|
|
145
|
+
|
|
146
|
+
def __getattr__(self_inner, item):
|
|
147
|
+
return getattr(ctx, item)
|
|
148
|
+
|
|
149
|
+
return _TracedStream()
|
|
150
|
+
|
|
151
|
+
patched_stream._agentx_patched = True
|
|
152
|
+
messages_resource.stream = patched_stream
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CrewAI integration for AgentX production tracing.
|
|
3
|
+
|
|
4
|
+
Usage::
|
|
5
|
+
|
|
6
|
+
from agentx.integrations.crewai import AgentXCrewObserver
|
|
7
|
+
|
|
8
|
+
observer = AgentXCrewObserver(agentx.tracer, name="my-crew")
|
|
9
|
+
result = observer.kickoff(crew, inputs={"topic": "AI"})
|
|
10
|
+
|
|
11
|
+
Or as a context manager around your own kickoff::
|
|
12
|
+
|
|
13
|
+
with observer.observe(name="my-crew", input={"topic": "AI"}) as span:
|
|
14
|
+
result = crew.kickoff(inputs={"topic": "AI"})
|
|
15
|
+
span.output = result.raw
|
|
16
|
+
|
|
17
|
+
Requires: ``pip install agentx[crewai]``
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import time
|
|
22
|
+
from typing import Any, Dict, Optional
|
|
23
|
+
|
|
24
|
+
from agentx.tracing.tracer import Tracer, _safe_serialize
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AgentXCrewObserver:
|
|
28
|
+
"""
|
|
29
|
+
Wraps a CrewAI ``Crew.kickoff()`` call and sends one trace per execution.
|
|
30
|
+
Does not require any CrewAI version-specific event system.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
tracer: Tracer,
|
|
36
|
+
name: str = "crewai-crew",
|
|
37
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
38
|
+
session_id: Optional[str] = None,
|
|
39
|
+
) -> None:
|
|
40
|
+
self._tracer = tracer
|
|
41
|
+
self._name = name
|
|
42
|
+
self._metadata = metadata
|
|
43
|
+
self._session_id = session_id
|
|
44
|
+
|
|
45
|
+
def kickoff(self, crew: Any, inputs: Optional[Dict[str, Any]] = None) -> Any:
|
|
46
|
+
"""
|
|
47
|
+
Call ``crew.kickoff(inputs=inputs)``, capture result, and send a trace.
|
|
48
|
+
Returns the raw CrewAI ``CrewOutput`` object unchanged.
|
|
49
|
+
"""
|
|
50
|
+
start = time.time()
|
|
51
|
+
error: Optional[str] = None
|
|
52
|
+
result = None
|
|
53
|
+
try:
|
|
54
|
+
result = crew.kickoff(inputs=inputs or {})
|
|
55
|
+
return result
|
|
56
|
+
except Exception as exc:
|
|
57
|
+
error = str(exc)
|
|
58
|
+
raise
|
|
59
|
+
finally:
|
|
60
|
+
latency_ms = int((time.time() - start) * 1000)
|
|
61
|
+
output = None
|
|
62
|
+
if result is not None:
|
|
63
|
+
output = getattr(result, "raw", None) or _safe_serialize(result)
|
|
64
|
+
|
|
65
|
+
# Collect task outputs as tool_calls for observability
|
|
66
|
+
tool_calls = []
|
|
67
|
+
if result is not None:
|
|
68
|
+
task_outputs = getattr(result, "tasks_output", []) or []
|
|
69
|
+
for task_out in task_outputs:
|
|
70
|
+
tool_calls.append(
|
|
71
|
+
{
|
|
72
|
+
"name": getattr(task_out, "description", "task")[:100],
|
|
73
|
+
"output": str(getattr(task_out, "raw", ""))[:500],
|
|
74
|
+
}
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
self._tracer._send(
|
|
78
|
+
name=self._name,
|
|
79
|
+
input=_safe_serialize(inputs) if inputs else None,
|
|
80
|
+
output=output,
|
|
81
|
+
latency_ms=latency_ms,
|
|
82
|
+
error=error,
|
|
83
|
+
framework="crewai",
|
|
84
|
+
tool_calls=tool_calls or None,
|
|
85
|
+
metadata=self._metadata,
|
|
86
|
+
session_id=self._session_id,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def observe(
|
|
90
|
+
self,
|
|
91
|
+
name: Optional[str] = None,
|
|
92
|
+
input: Optional[Any] = None,
|
|
93
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
94
|
+
session_id: Optional[str] = None,
|
|
95
|
+
):
|
|
96
|
+
"""Return a context-manager span for manual kickoff wrapping."""
|
|
97
|
+
from agentx.tracing.tracer import _TraceSpan
|
|
98
|
+
|
|
99
|
+
return _TraceSpan(
|
|
100
|
+
tracer=self._tracer,
|
|
101
|
+
name=name or self._name,
|
|
102
|
+
input=_safe_serialize(input) if input is not None else None,
|
|
103
|
+
metadata=metadata or self._metadata,
|
|
104
|
+
framework="crewai",
|
|
105
|
+
session_id=session_id or self._session_id,
|
|
106
|
+
)
|