agentx-python 0.6.26__tar.gz → 0.6.28__tar.gz
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_python-0.6.26/agentx_python.egg-info → agentx_python-0.6.28}/PKG-INFO +1 -1
- {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/evaluations/client.py +167 -11
- {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/evaluations/runner.py +8 -1
- {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/tracing/tracer.py +31 -3
- agentx_python-0.6.28/agentx/version.py +1 -0
- {agentx_python-0.6.26 → agentx_python-0.6.28/agentx_python.egg-info}/PKG-INFO +1 -1
- {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx_python.egg-info/SOURCES.txt +1 -0
- agentx_python-0.6.28/tests/test_selfhost_analysis_fallback.py +251 -0
- agentx_python-0.6.26/agentx/version.py +0 -1
- {agentx_python-0.6.26 → agentx_python-0.6.28}/LICENSE +0 -0
- {agentx_python-0.6.26 → agentx_python-0.6.28}/README.md +0 -0
- {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/__init__.py +0 -0
- {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/agentx.py +0 -0
- {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/cli.py +0 -0
- {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/evaluations/__init__.py +0 -0
- {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/evaluations/_term.py +0 -0
- {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/evaluations/adapters/__init__.py +0 -0
- {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/evaluations/adapters/http_endpoint.py +0 -0
- {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/evaluations/adapters/precomputed.py +0 -0
- {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/evaluations/adapters/raw.py +0 -0
- {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/evaluations/datasets.py +0 -0
- {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/evaluations/evaluation_settings.py +0 -0
- {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/evaluations/models.py +0 -0
- {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/evaluations/prompts.py +0 -0
- {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/evaluations/redaction.py +0 -0
- {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/evaluations/reporting.py +0 -0
- {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/evaluations/results.py +0 -0
- {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/evaluations/tracing.py +0 -0
- {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/exceptions.py +0 -0
- {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/feedback.py +0 -0
- {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/integrations/__init__.py +0 -0
- {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/integrations/_traced_call.py +0 -0
- {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/integrations/anthropic.py +0 -0
- {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/integrations/autogen.py +0 -0
- {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/integrations/crewai.py +0 -0
- {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/integrations/databricks.py +0 -0
- {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/integrations/google_adk.py +0 -0
- {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/integrations/google_genai.py +0 -0
- {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/integrations/langchain.py +0 -0
- {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/integrations/litellm.py +0 -0
- {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/integrations/llamaindex.py +0 -0
- {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/integrations/moveworks.py +0 -0
- {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/integrations/openai.py +0 -0
- {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/integrations/openai_agents.py +0 -0
- {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/monitor/__init__.py +0 -0
- {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/monitor/client.py +0 -0
- {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/monitor/models.py +0 -0
- {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/monitor/online_evaluators.py +0 -0
- {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/monitor/patterns.py +0 -0
- {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/monitor/profile.py +0 -0
- {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/monitor/signals.py +0 -0
- {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/outcomes.py +0 -0
- {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/py.typed +0 -0
- {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/resources/__init__.py +0 -0
- {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/resources/agent.py +0 -0
- {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/resources/conversation.py +0 -0
- {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/resources/workforce.py +0 -0
- {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/tracing/__init__.py +0 -0
- {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/tracing/ci_types.py +0 -0
- {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/tracing/ingest_client.py +0 -0
- {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/util.py +0 -0
- {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx_python.egg-info/dependency_links.txt +0 -0
- {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx_python.egg-info/entry_points.txt +0 -0
- {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx_python.egg-info/not-zip-safe +0 -0
- {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx_python.egg-info/requires.txt +0 -0
- {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx_python.egg-info/top_level.txt +0 -0
- {agentx_python-0.6.26 → agentx_python-0.6.28}/setup.cfg +0 -0
- {agentx_python-0.6.26 → agentx_python-0.6.28}/setup.py +0 -0
- {agentx_python-0.6.26 → agentx_python-0.6.28}/tests/test_integration.py +0 -0
- {agentx_python-0.6.26 → agentx_python-0.6.28}/tests/test_integrations.py +0 -0
- {agentx_python-0.6.26 → agentx_python-0.6.28}/tests/test_span_tree.py +0 -0
|
@@ -32,9 +32,23 @@ _RETRYABLE_STATUS = {429, 500, 502, 503, 504}
|
|
|
32
32
|
_MAX_RETRIES = 3
|
|
33
33
|
_RETRY_BACKOFF = [1.0, 2.0, 4.0]
|
|
34
34
|
|
|
35
|
+
# The self-host analyze route judges every result before it responds, so the client has to
|
|
36
|
+
# wait out the whole job on one connection. Matches EvaluationRunContext.analyze()'s own
|
|
37
|
+
# default timeout.
|
|
38
|
+
_SELF_HOST_ANALYZE_TIMEOUT = 1800
|
|
39
|
+
|
|
35
40
|
|
|
36
41
|
class AgentXEvaluationsError(Exception):
|
|
37
|
-
|
|
42
|
+
"""An evaluations API call failed.
|
|
43
|
+
|
|
44
|
+
``status_code`` carries the HTTP status when the failure came from a response rather
|
|
45
|
+
than from the transport, so callers can branch on it instead of matching on the message
|
|
46
|
+
text. It is ``None`` for connection errors and for retry exhaustion.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, message: str, status_code: Optional[int] = None) -> None:
|
|
50
|
+
super().__init__(message)
|
|
51
|
+
self.status_code = status_code
|
|
38
52
|
|
|
39
53
|
|
|
40
54
|
class AgentXAuthError(AgentXEvaluationsError):
|
|
@@ -69,6 +83,8 @@ class EvaluationsClient:
|
|
|
69
83
|
if not _api_base.endswith("/custom-agent-evaluations"):
|
|
70
84
|
_api_base = f"{_api_base}/custom-agent-evaluations"
|
|
71
85
|
self._base_url = _api_base
|
|
86
|
+
# None until an analysis call tells us which engine this is; see _api_root.
|
|
87
|
+
self._analysis_on_dashboard_router: Optional[bool] = None
|
|
72
88
|
self._session = requests.Session()
|
|
73
89
|
self._session.headers.update(
|
|
74
90
|
{
|
|
@@ -103,10 +119,29 @@ class EvaluationsClient:
|
|
|
103
119
|
"""Same as _with_workspace(), for GET requests that take workspaceId as a query param."""
|
|
104
120
|
return {"workspaceId": self._workspace_id} if self._workspace_id else None
|
|
105
121
|
|
|
106
|
-
def _request(
|
|
107
|
-
|
|
122
|
+
def _request(
|
|
123
|
+
self,
|
|
124
|
+
method: str,
|
|
125
|
+
path: str,
|
|
126
|
+
timeout: int = 30,
|
|
127
|
+
base: Optional[str] = None,
|
|
128
|
+
retry: bool = True,
|
|
129
|
+
**kwargs,
|
|
130
|
+
) -> Any:
|
|
131
|
+
"""Call the evaluations API.
|
|
132
|
+
|
|
133
|
+
``base`` overrides the ``/custom-agent-evaluations`` prefix for the handful of
|
|
134
|
+
routes that live on a different router (see ``_api_root``).
|
|
135
|
+
|
|
136
|
+
``retry=False`` disables the backoff loop entirely. Use it for any request that is
|
|
137
|
+
both slow and billable: the loop retries on ``requests.RequestException``, which
|
|
138
|
+
includes read timeouts, so a synchronous endpoint that outlives its timeout would
|
|
139
|
+
otherwise be re-invoked — and paid for — up to four times.
|
|
140
|
+
"""
|
|
141
|
+
url = f"{base or self._base_url}{path}"
|
|
142
|
+
schedule = [0.0] + (_RETRY_BACKOFF if retry else [])
|
|
108
143
|
last_exc: Optional[Exception] = None
|
|
109
|
-
for attempt, wait in enumerate(
|
|
144
|
+
for attempt, wait in enumerate(schedule):
|
|
110
145
|
if wait:
|
|
111
146
|
time.sleep(wait)
|
|
112
147
|
try:
|
|
@@ -120,14 +155,22 @@ class EvaluationsClient:
|
|
|
120
155
|
raise AgentXAuthError("Invalid or missing API key")
|
|
121
156
|
if resp.status_code == 422:
|
|
122
157
|
raise AgentXValidationError(resp.text)
|
|
123
|
-
if
|
|
158
|
+
if (
|
|
159
|
+
resp.status_code in _RETRYABLE_STATUS
|
|
160
|
+
and retry
|
|
161
|
+
and attempt < _MAX_RETRIES - 1
|
|
162
|
+
):
|
|
124
163
|
logger.debug(
|
|
125
164
|
"Retryable status %d (attempt %d)", resp.status_code, attempt + 1
|
|
126
165
|
)
|
|
127
|
-
last_exc = AgentXEvaluationsError(
|
|
166
|
+
last_exc = AgentXEvaluationsError(
|
|
167
|
+
f"HTTP {resp.status_code}", status_code=resp.status_code
|
|
168
|
+
)
|
|
128
169
|
continue
|
|
129
170
|
if not resp.ok:
|
|
130
|
-
raise AgentXEvaluationsError(
|
|
171
|
+
raise AgentXEvaluationsError(
|
|
172
|
+
f"HTTP {resp.status_code}: {resp.text}", status_code=resp.status_code
|
|
173
|
+
)
|
|
131
174
|
try:
|
|
132
175
|
return resp.json()
|
|
133
176
|
except Exception:
|
|
@@ -303,6 +346,10 @@ class EvaluationsClient:
|
|
|
303
346
|
# Starts the durable analysis job and returns immediately (e.g. {"jobId": ..., "status":
|
|
304
347
|
# "pending"}); poll get_analysis_status() until it reaches a terminal status, then call
|
|
305
348
|
# get_report(). mode/quality_mode/judges mirror the dashboard's AnalyzeEvaluationRequest.
|
|
349
|
+
#
|
|
350
|
+
# On self-host the fallback route runs the analysis synchronously and returns only when
|
|
351
|
+
# it is done, so the caller's poll loop sees a terminal status on its first check. The
|
|
352
|
+
# request is therefore given the full analysis timeout and, critically, no retries.
|
|
306
353
|
payload: Dict[str, Any] = {}
|
|
307
354
|
if mode is not None:
|
|
308
355
|
payload["mode"] = mode
|
|
@@ -310,23 +357,132 @@ class EvaluationsClient:
|
|
|
310
357
|
payload["qualityMode"] = quality_mode
|
|
311
358
|
if judges is not None:
|
|
312
359
|
payload["judges"] = [{"model": m} for m in judges]
|
|
313
|
-
|
|
360
|
+
|
|
361
|
+
if not self._analysis_on_dashboard_router:
|
|
362
|
+
try:
|
|
363
|
+
return self._request(
|
|
364
|
+
"POST", f"/runs/{run_id}/analyze", json=payload, timeout=30
|
|
365
|
+
)
|
|
366
|
+
except AgentXEvaluationsError as exc:
|
|
367
|
+
if not self._note_missing_analysis_route(exc, "analyze"):
|
|
368
|
+
raise
|
|
369
|
+
|
|
370
|
+
return self._request(
|
|
371
|
+
"POST",
|
|
372
|
+
f"/evaluate/analyze/{run_id}",
|
|
373
|
+
base=self._api_root,
|
|
374
|
+
json=payload,
|
|
375
|
+
timeout=_SELF_HOST_ANALYZE_TIMEOUT,
|
|
376
|
+
retry=False,
|
|
377
|
+
)
|
|
314
378
|
|
|
315
379
|
def get_analysis_status(self, run_id: str) -> AnalysisStatus:
|
|
316
|
-
|
|
380
|
+
if not self._analysis_on_dashboard_router:
|
|
381
|
+
try:
|
|
382
|
+
return AnalysisStatus(
|
|
383
|
+
**self._request("GET", f"/runs/{run_id}/analyze-status")
|
|
384
|
+
)
|
|
385
|
+
except AgentXEvaluationsError as exc:
|
|
386
|
+
if not self._note_missing_analysis_route(exc, "analyze-status"):
|
|
387
|
+
raise
|
|
388
|
+
|
|
389
|
+
data = self._request(
|
|
390
|
+
"GET", f"/evaluate/analyze/{run_id}/status", base=self._api_root
|
|
391
|
+
)
|
|
317
392
|
return AnalysisStatus(**data)
|
|
318
393
|
|
|
319
394
|
def get_run(self, run_id: str) -> Dict[str, Any]:
|
|
320
395
|
return self._request("GET", f"/runs/{run_id}")
|
|
321
396
|
|
|
322
397
|
def get_report(self, run_id: str) -> Report:
|
|
323
|
-
|
|
324
|
-
|
|
398
|
+
if not self._analysis_on_dashboard_router:
|
|
399
|
+
try:
|
|
400
|
+
return Report(**self._request("GET", f"/runs/{run_id}/report"))
|
|
401
|
+
except AgentXEvaluationsError as exc:
|
|
402
|
+
if not self._note_missing_analysis_route(exc, "report"):
|
|
403
|
+
raise
|
|
404
|
+
|
|
405
|
+
return self._report_from_dashboard(run_id)
|
|
325
406
|
|
|
326
407
|
def get_missing_results(self, run_id: str) -> List[Dict[str, Any]]:
|
|
327
408
|
data = self._request("GET", f"/runs/{run_id}/missing-results")
|
|
328
409
|
return data if isinstance(data, list) else data.get("missing", [])
|
|
329
410
|
|
|
411
|
+
# ------------------------------------------------------------------
|
|
412
|
+
# Self-host analysis fallback
|
|
413
|
+
#
|
|
414
|
+
# The self-host engine (AgentX-trace-eval) mounts two routers: the SDK's
|
|
415
|
+
# /custom-agent-evaluations, and /evaluate for the dashboard. Its
|
|
416
|
+
# /custom-agent-evaluations router implements the run lifecycle - runs, results,
|
|
417
|
+
# finalize, gate - but not /analyze, /analyze-status or /report, which exist only on
|
|
418
|
+
# /evaluate. Hosted AgentX serves all of them from the SDK's own router.
|
|
419
|
+
#
|
|
420
|
+
# So the three analysis calls try the SDK route first and fall back to the dashboard
|
|
421
|
+
# route on a 404, which means hosted behaviour is byte-for-byte unchanged: it never
|
|
422
|
+
# 404s, so it never falls back. The outcome is cached on the client so the probe costs
|
|
423
|
+
# one request per process, not one per call.
|
|
424
|
+
# ------------------------------------------------------------------
|
|
425
|
+
|
|
426
|
+
@property
|
|
427
|
+
def _api_root(self) -> str:
|
|
428
|
+
"""The API base with the ``/custom-agent-evaluations`` suffix removed."""
|
|
429
|
+
suffix = "/custom-agent-evaluations"
|
|
430
|
+
if self._base_url.endswith(suffix):
|
|
431
|
+
return self._base_url[: -len(suffix)]
|
|
432
|
+
return self._base_url
|
|
433
|
+
|
|
434
|
+
def _note_missing_analysis_route(
|
|
435
|
+
self, exc: AgentXEvaluationsError, route: str
|
|
436
|
+
) -> bool:
|
|
437
|
+
"""Return True if ``exc`` is the 404 that means "this engine is self-host".
|
|
438
|
+
|
|
439
|
+
Only a 404 qualifies. Anything else - auth, validation, a 500, a dead connection -
|
|
440
|
+
is a real failure on a route that does exist, and must propagate rather than be
|
|
441
|
+
retried against a different endpoint that would mask it.
|
|
442
|
+
"""
|
|
443
|
+
if exc.status_code != 404:
|
|
444
|
+
return False
|
|
445
|
+
if self._analysis_on_dashboard_router is None:
|
|
446
|
+
logger.info(
|
|
447
|
+
"%s is not served from %s; using the dashboard router at %s "
|
|
448
|
+
"(self-host engine)",
|
|
449
|
+
route,
|
|
450
|
+
self._base_url,
|
|
451
|
+
self._api_root,
|
|
452
|
+
)
|
|
453
|
+
self._analysis_on_dashboard_router = True
|
|
454
|
+
return True
|
|
455
|
+
|
|
456
|
+
def _report_from_dashboard(self, run_id: str) -> Report:
|
|
457
|
+
"""Assemble a Report from the dashboard's evaluation record.
|
|
458
|
+
|
|
459
|
+
Self-host has no /report route; it returns the analysis nested inside the
|
|
460
|
+
evaluation itself, under ``analysis.analysis`` with its statistics one level up.
|
|
461
|
+
The field names already match the Report models, so this is a reshape, not a
|
|
462
|
+
translation.
|
|
463
|
+
"""
|
|
464
|
+
record = self._request("GET", f"/evaluate/{run_id}", base=self._api_root)
|
|
465
|
+
envelope = record.get("analysis") or {}
|
|
466
|
+
body = envelope.get("analysis") or {}
|
|
467
|
+
|
|
468
|
+
if not envelope:
|
|
469
|
+
raise AgentXEvaluationsError(
|
|
470
|
+
f"Run {run_id} has no analysis to report. Nothing has analyzed it yet, "
|
|
471
|
+
"or the analysis failed - call analyze_run() first."
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
dataset_id = record.get("datasetId")
|
|
475
|
+
if isinstance(dataset_id, dict): # populated reference, not a bare id
|
|
476
|
+
dataset_id = dataset_id.get("_id") or dataset_id.get("id")
|
|
477
|
+
|
|
478
|
+
return Report(
|
|
479
|
+
runId=run_id,
|
|
480
|
+
datasetId=dataset_id or "",
|
|
481
|
+
status=envelope.get("status") or "completed",
|
|
482
|
+
statistics=envelope.get("statistics"),
|
|
483
|
+
**body,
|
|
484
|
+
)
|
|
485
|
+
|
|
330
486
|
|
|
331
487
|
# ---------------------------------------------------------------------------
|
|
332
488
|
# Helpers
|
|
@@ -362,11 +362,18 @@ class EvaluationRunContext:
|
|
|
362
362
|
try:
|
|
363
363
|
report = self._client.get_report(self._run.run_id)
|
|
364
364
|
except Exception as exc:
|
|
365
|
+
# Deliberately not status="completed". A placeholder that claims completion is
|
|
366
|
+
# indistinguishable from a real report of an evaluation that scored nothing -
|
|
367
|
+
# print_report renders empty statistics and no recommendations either way - so
|
|
368
|
+
# the one signal that something went wrong used to be a logger.warning that is
|
|
369
|
+
# invisible unless the caller configured logging. Say it on stdout, and let the
|
|
370
|
+
# status carry the truth for anything reading the object.
|
|
371
|
+
print(f" {red('✗')} Could not fetch the report: {dim(str(exc))}")
|
|
365
372
|
logger.warning("Could not fetch report: %s", exc)
|
|
366
373
|
report = Report(
|
|
367
374
|
runId=self._run.run_id,
|
|
368
375
|
datasetId=self._dataset.id,
|
|
369
|
-
status="
|
|
376
|
+
status="unavailable",
|
|
370
377
|
)
|
|
371
378
|
|
|
372
379
|
self._report = report
|
|
@@ -414,6 +414,7 @@ class _TraceSpan:
|
|
|
414
414
|
duration_ms=step.get("duration_ms"),
|
|
415
415
|
input=step.get("query"),
|
|
416
416
|
output=step.get("output"),
|
|
417
|
+
metadata={"kind": "retrieval"},
|
|
417
418
|
)
|
|
418
419
|
|
|
419
420
|
if self.input is None and input is not None:
|
|
@@ -561,6 +562,7 @@ class Tracer:
|
|
|
561
562
|
def __init__(self, ingest_client: IngestClient) -> None:
|
|
562
563
|
self._client = ingest_client
|
|
563
564
|
self._pending_tool_calls: List[Dict[str, Any]] = []
|
|
565
|
+
self._pending_retrievals: List[Dict[str, Any]] = []
|
|
564
566
|
self._local = threading.local()
|
|
565
567
|
|
|
566
568
|
# ------------------------------------------------------------------
|
|
@@ -736,12 +738,27 @@ class Tracer:
|
|
|
736
738
|
Manually record a knowledge-base / vector-store retrieval that an
|
|
737
739
|
auto-instrumented framework integration can't see on its own - e.g. a
|
|
738
740
|
hand-rolled RAG lookup wrapped around a raw Anthropic/OpenAI call or a
|
|
739
|
-
CrewAI kickoff. Sent as a real child-span row of the active span (see ``current_span``)
|
|
740
|
-
|
|
741
|
-
|
|
741
|
+
CrewAI kickoff. Sent as a real child-span row of the active span (see ``current_span``);
|
|
742
|
+
with no active span it queues and merges into the very next trace this tracer sends
|
|
743
|
+
(same behavior as ``record_tool_call``, covering the patched-client flow where the
|
|
744
|
+
retrieval runs just before a standalone ``messages.create()`` /
|
|
745
|
+
``chat.completions.create()`` call).
|
|
742
746
|
"""
|
|
743
747
|
active_span = self.current_span
|
|
744
748
|
if active_span is None:
|
|
749
|
+
latency_ms = (
|
|
750
|
+
int(duration_ms)
|
|
751
|
+
if duration_ms is not None
|
|
752
|
+
else int((end_time - start_time) * 1000)
|
|
753
|
+
if start_time is not None and end_time is not None
|
|
754
|
+
else None
|
|
755
|
+
)
|
|
756
|
+
self._pending_retrievals.append({
|
|
757
|
+
"name": name,
|
|
758
|
+
"query": _safe_serialize(query) if query is not None else None,
|
|
759
|
+
"output": _safe_serialize(output) if output is not None else None,
|
|
760
|
+
"duration_ms": latency_ms,
|
|
761
|
+
})
|
|
745
762
|
return
|
|
746
763
|
# The kind marker is what tells the engine (retrieval-context extraction for RAG
|
|
747
764
|
# judges) and the dashboard timeline that this span is a retrieval regardless of its
|
|
@@ -1104,6 +1121,17 @@ class Tracer:
|
|
|
1104
1121
|
# a projection that predates them would silently strip exactly the failure evidence.
|
|
1105
1122
|
wire["tool_calls"] = list(wire.get("tool_calls") or []) + [dict(t) for t in pending_tool_calls]
|
|
1106
1123
|
|
|
1124
|
+
# record_retrieval entries queued with no active span ride the root's
|
|
1125
|
+
# performance_summary.retrieval_steps - the same shape older flat traces used, which the
|
|
1126
|
+
# engine's retrieval-context extraction and the dashboard's references panel both read.
|
|
1127
|
+
pending_retrievals, self._pending_retrievals = self._pending_retrievals, []
|
|
1128
|
+
if pending_retrievals:
|
|
1129
|
+
summary = dict(wire.get("performance_summary") or {})
|
|
1130
|
+
summary["retrieval_steps"] = list(summary.get("retrieval_steps") or []) + [
|
|
1131
|
+
dict(r) for r in pending_retrievals
|
|
1132
|
+
]
|
|
1133
|
+
wire["performance_summary"] = summary
|
|
1134
|
+
|
|
1107
1135
|
return self._dispatch(wire, sync=sync)
|
|
1108
1136
|
|
|
1109
1137
|
def _dispatch(self, wire: Dict[str, Any], *, sync: bool = False) -> Optional[str]:
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
VERSION = "0.6.28"
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""The analysis calls' self-host fallback.
|
|
2
|
+
|
|
3
|
+
Self-host (AgentX-trace-eval) serves whole-run analysis from its dashboard router, not from
|
|
4
|
+
the /custom-agent-evaluations router the SDK targets. Older engines lack the SDK routes
|
|
5
|
+
entirely, so the three analysis calls try the SDK route and fall back on a 404.
|
|
6
|
+
|
|
7
|
+
What these tests pin down is the part that is easy to get wrong: the fallback must be
|
|
8
|
+
invisible to hosted AgentX, must not swallow anything other than a 404, and must not retry a
|
|
9
|
+
billable synchronous request.
|
|
10
|
+
|
|
11
|
+
No network and no API key - the HTTP session is replaced with a recorder.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
from agentx.evaluations.client import (
|
|
17
|
+
AgentXAuthError,
|
|
18
|
+
AgentXEvaluationsError,
|
|
19
|
+
EvaluationsClient,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
API_ROOT = "https://example.test/api/v1"
|
|
23
|
+
SDK_ROOT = f"{API_ROOT}/custom-agent-evaluations"
|
|
24
|
+
RUN = "run-123"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class FakeResponse:
|
|
28
|
+
def __init__(self, status_code: int, payload=None):
|
|
29
|
+
self.status_code = status_code
|
|
30
|
+
self._payload = payload if payload is not None else {}
|
|
31
|
+
self.text = str(self._payload)
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def ok(self) -> bool:
|
|
35
|
+
return self.status_code < 400
|
|
36
|
+
|
|
37
|
+
def json(self):
|
|
38
|
+
return self._payload
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class FakeSession:
|
|
42
|
+
"""Records every call and answers from a {(method, url): [responses]} routing table."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, routes):
|
|
45
|
+
self.routes = routes
|
|
46
|
+
self.calls = []
|
|
47
|
+
self.headers = {}
|
|
48
|
+
|
|
49
|
+
def request(self, method, url, **kwargs):
|
|
50
|
+
self.calls.append((method, url, kwargs))
|
|
51
|
+
key = (method, url)
|
|
52
|
+
if key not in self.routes:
|
|
53
|
+
return FakeResponse(404, {"message": "Not found"})
|
|
54
|
+
answer = self.routes[key]
|
|
55
|
+
if isinstance(answer, list):
|
|
56
|
+
return answer.pop(0) if len(answer) > 1 else answer[0]
|
|
57
|
+
return answer
|
|
58
|
+
|
|
59
|
+
def urls(self, method=None):
|
|
60
|
+
return [u for m, u, _ in self.calls if method is None or m == method]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def make_client(routes):
|
|
64
|
+
client = EvaluationsClient(api_key="k", base_url=API_ROOT)
|
|
65
|
+
session = FakeSession(routes)
|
|
66
|
+
client._session = session
|
|
67
|
+
return client, session
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
STATUS_BODY = {
|
|
71
|
+
"evaluationId": RUN,
|
|
72
|
+
"jobId": RUN,
|
|
73
|
+
"status": "completed",
|
|
74
|
+
"progress": {"overallPercentage": 100, "currentLevel": None, "levels": {}},
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
REPORT_BODY = {
|
|
78
|
+
"runId": RUN,
|
|
79
|
+
"datasetId": "ds-1",
|
|
80
|
+
"status": "completed",
|
|
81
|
+
"summary": "It went fine.",
|
|
82
|
+
"recommendations": [{"category": "instructions", "priority": "high"}],
|
|
83
|
+
"statistics": {"numberOfRuns": 4, "averageRating": 7.5, "minRating": 5, "maxRating": 9},
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
# Hosted AgentX: the SDK routes answer, so nothing may change
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_hosted_uses_the_sdk_routes_and_never_probes_the_dashboard():
|
|
93
|
+
client, session = make_client(
|
|
94
|
+
{
|
|
95
|
+
("POST", f"{SDK_ROOT}/runs/{RUN}/analyze"): FakeResponse(200, {"status": "pending"}),
|
|
96
|
+
("GET", f"{SDK_ROOT}/runs/{RUN}/analyze-status"): FakeResponse(200, STATUS_BODY),
|
|
97
|
+
("GET", f"{SDK_ROOT}/runs/{RUN}/report"): FakeResponse(200, REPORT_BODY),
|
|
98
|
+
}
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
client.analyze_run(RUN)
|
|
102
|
+
assert client.get_analysis_status(RUN).status == "completed"
|
|
103
|
+
assert client.get_report(RUN).summary == "It went fine."
|
|
104
|
+
|
|
105
|
+
assert all("/custom-agent-evaluations/" in url for url in session.urls())
|
|
106
|
+
assert client._analysis_on_dashboard_router is None, "hosted must never flip the flag"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
# Self-host: the SDK routes 404, the dashboard routes answer
|
|
111
|
+
# ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_analysis_status_falls_back_to_the_dashboard_router_on_404():
|
|
115
|
+
client, session = make_client(
|
|
116
|
+
{("GET", f"{API_ROOT}/evaluate/analyze/{RUN}/status"): FakeResponse(200, STATUS_BODY)}
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
assert client.get_analysis_status(RUN).is_terminal is True
|
|
120
|
+
assert session.urls("GET") == [
|
|
121
|
+
f"{SDK_ROOT}/runs/{RUN}/analyze-status",
|
|
122
|
+
f"{API_ROOT}/evaluate/analyze/{RUN}/status",
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_the_fallback_is_probed_once_then_remembered():
|
|
127
|
+
client, session = make_client(
|
|
128
|
+
{("GET", f"{API_ROOT}/evaluate/analyze/{RUN}/status"): FakeResponse(200, STATUS_BODY)}
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
client.get_analysis_status(RUN)
|
|
132
|
+
client.get_analysis_status(RUN)
|
|
133
|
+
|
|
134
|
+
# One 404 probe, not one per call.
|
|
135
|
+
assert session.urls("GET").count(f"{SDK_ROOT}/runs/{RUN}/analyze-status") == 1
|
|
136
|
+
assert client._analysis_on_dashboard_router is True
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_report_is_assembled_from_the_dashboard_evaluation_record():
|
|
140
|
+
client, _ = make_client(
|
|
141
|
+
{
|
|
142
|
+
("GET", f"{API_ROOT}/evaluate/{RUN}"): FakeResponse(
|
|
143
|
+
200,
|
|
144
|
+
{
|
|
145
|
+
# datasetId arrives as a populated reference here, not a bare id.
|
|
146
|
+
"datasetId": {"_id": "ds-1", "name": "Support"},
|
|
147
|
+
"analysis": {
|
|
148
|
+
"status": "completed",
|
|
149
|
+
"statistics": {"numberOfRuns": 16, "averageRating": 6.21875,
|
|
150
|
+
"minRating": 1, "maxRating": 9.5},
|
|
151
|
+
"analysis": {
|
|
152
|
+
"summary": "Uneven.",
|
|
153
|
+
"recommendations": [
|
|
154
|
+
{"category": "instructions", "priority": "high",
|
|
155
|
+
"recommendation": "Cite the doc id.", "reasoning": "None cited."}
|
|
156
|
+
],
|
|
157
|
+
# A key the SDK's Report does not model; must not raise.
|
|
158
|
+
"overallAssessment": "mixed",
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
report = client.get_report(RUN)
|
|
167
|
+
|
|
168
|
+
assert report.run_id == RUN
|
|
169
|
+
assert report.dataset_id == "ds-1", "the populated reference must be unwrapped to its id"
|
|
170
|
+
assert report.status == "completed"
|
|
171
|
+
assert report.summary == "Uneven."
|
|
172
|
+
assert report.statistics.average_rating == 6.21875
|
|
173
|
+
assert report.statistics.min_rating == 1
|
|
174
|
+
assert len(report.recommendations) == 1
|
|
175
|
+
assert report.recommendations[0].category == "instructions"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def test_report_says_so_when_nothing_has_analyzed_the_run():
|
|
179
|
+
client, _ = make_client(
|
|
180
|
+
{("GET", f"{API_ROOT}/evaluate/{RUN}"): FakeResponse(200, {"datasetId": "ds-1"})}
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
with pytest.raises(AgentXEvaluationsError, match="no analysis"):
|
|
184
|
+
client.get_report(RUN)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def test_the_synchronous_fallback_analyze_is_never_retried():
|
|
188
|
+
"""A read timeout on a billable sync endpoint must not re-run the judges."""
|
|
189
|
+
import requests
|
|
190
|
+
|
|
191
|
+
client, session = make_client({})
|
|
192
|
+
|
|
193
|
+
def always_times_out(method, url, **kwargs):
|
|
194
|
+
session.calls.append((method, url, kwargs))
|
|
195
|
+
if url.endswith("/analyze"):
|
|
196
|
+
return FakeResponse(404, {"message": "Not found"})
|
|
197
|
+
raise requests.exceptions.ReadTimeout("too slow")
|
|
198
|
+
|
|
199
|
+
session.request = always_times_out
|
|
200
|
+
|
|
201
|
+
with pytest.raises(AgentXEvaluationsError):
|
|
202
|
+
client.analyze_run(RUN)
|
|
203
|
+
|
|
204
|
+
dashboard_posts = [u for u in session.urls("POST") if "/evaluate/analyze/" in u]
|
|
205
|
+
assert len(dashboard_posts) == 1, f"retried a billable request: {dashboard_posts}"
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def test_the_fallback_request_gets_the_long_analysis_timeout():
|
|
209
|
+
client, session = make_client(
|
|
210
|
+
{("POST", f"{API_ROOT}/evaluate/analyze/{RUN}"): FakeResponse(200, {"status": "completed"})}
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
client.analyze_run(RUN, judges=["gpt-5.5"])
|
|
214
|
+
|
|
215
|
+
method, url, kwargs = session.calls[-1]
|
|
216
|
+
assert url == f"{API_ROOT}/evaluate/analyze/{RUN}"
|
|
217
|
+
assert kwargs["timeout"] > 60, "a synchronous judge pass needs more than the 30s default"
|
|
218
|
+
assert kwargs["json"]["judges"] == [{"model": "gpt-5.5"}]
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# ---------------------------------------------------------------------------
|
|
222
|
+
# Only a 404 means "wrong engine"
|
|
223
|
+
# ---------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@pytest.mark.parametrize("status", [400, 403, 500])
|
|
227
|
+
def test_failures_that_are_not_404_propagate_untouched(status):
|
|
228
|
+
client, session = make_client(
|
|
229
|
+
{
|
|
230
|
+
("GET", f"{SDK_ROOT}/runs/{RUN}/analyze-status"): FakeResponse(status, {"e": "boom"}),
|
|
231
|
+
# Present, and must not be reached.
|
|
232
|
+
("GET", f"{API_ROOT}/evaluate/analyze/{RUN}/status"): FakeResponse(200, STATUS_BODY),
|
|
233
|
+
}
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
with pytest.raises(AgentXEvaluationsError) as caught:
|
|
237
|
+
client.get_analysis_status(RUN)
|
|
238
|
+
|
|
239
|
+
assert caught.value.status_code == status
|
|
240
|
+
assert not [u for u in session.urls() if "/evaluate/" in u], "masked a real failure"
|
|
241
|
+
assert client._analysis_on_dashboard_router is None
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def test_auth_errors_are_not_mistaken_for_a_missing_route():
|
|
245
|
+
client, session = make_client(
|
|
246
|
+
{("GET", f"{SDK_ROOT}/runs/{RUN}/analyze-status"): FakeResponse(401, {"e": "nope"})}
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
with pytest.raises(AgentXAuthError):
|
|
250
|
+
client.get_analysis_status(RUN)
|
|
251
|
+
assert not [u for u in session.urls() if "/evaluate/" in u]
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
VERSION = "0.6.26"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|