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.
Files changed (71) hide show
  1. {agentx_python-0.6.26/agentx_python.egg-info → agentx_python-0.6.28}/PKG-INFO +1 -1
  2. {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/evaluations/client.py +167 -11
  3. {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/evaluations/runner.py +8 -1
  4. {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/tracing/tracer.py +31 -3
  5. agentx_python-0.6.28/agentx/version.py +1 -0
  6. {agentx_python-0.6.26 → agentx_python-0.6.28/agentx_python.egg-info}/PKG-INFO +1 -1
  7. {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx_python.egg-info/SOURCES.txt +1 -0
  8. agentx_python-0.6.28/tests/test_selfhost_analysis_fallback.py +251 -0
  9. agentx_python-0.6.26/agentx/version.py +0 -1
  10. {agentx_python-0.6.26 → agentx_python-0.6.28}/LICENSE +0 -0
  11. {agentx_python-0.6.26 → agentx_python-0.6.28}/README.md +0 -0
  12. {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/__init__.py +0 -0
  13. {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/agentx.py +0 -0
  14. {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/cli.py +0 -0
  15. {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/evaluations/__init__.py +0 -0
  16. {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/evaluations/_term.py +0 -0
  17. {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/evaluations/adapters/__init__.py +0 -0
  18. {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/evaluations/adapters/http_endpoint.py +0 -0
  19. {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/evaluations/adapters/precomputed.py +0 -0
  20. {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/evaluations/adapters/raw.py +0 -0
  21. {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/evaluations/datasets.py +0 -0
  22. {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/evaluations/evaluation_settings.py +0 -0
  23. {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/evaluations/models.py +0 -0
  24. {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/evaluations/prompts.py +0 -0
  25. {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/evaluations/redaction.py +0 -0
  26. {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/evaluations/reporting.py +0 -0
  27. {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/evaluations/results.py +0 -0
  28. {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/evaluations/tracing.py +0 -0
  29. {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/exceptions.py +0 -0
  30. {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/feedback.py +0 -0
  31. {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/integrations/__init__.py +0 -0
  32. {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/integrations/_traced_call.py +0 -0
  33. {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/integrations/anthropic.py +0 -0
  34. {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/integrations/autogen.py +0 -0
  35. {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/integrations/crewai.py +0 -0
  36. {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/integrations/databricks.py +0 -0
  37. {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/integrations/google_adk.py +0 -0
  38. {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/integrations/google_genai.py +0 -0
  39. {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/integrations/langchain.py +0 -0
  40. {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/integrations/litellm.py +0 -0
  41. {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/integrations/llamaindex.py +0 -0
  42. {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/integrations/moveworks.py +0 -0
  43. {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/integrations/openai.py +0 -0
  44. {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/integrations/openai_agents.py +0 -0
  45. {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/monitor/__init__.py +0 -0
  46. {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/monitor/client.py +0 -0
  47. {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/monitor/models.py +0 -0
  48. {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/monitor/online_evaluators.py +0 -0
  49. {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/monitor/patterns.py +0 -0
  50. {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/monitor/profile.py +0 -0
  51. {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/monitor/signals.py +0 -0
  52. {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/outcomes.py +0 -0
  53. {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/py.typed +0 -0
  54. {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/resources/__init__.py +0 -0
  55. {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/resources/agent.py +0 -0
  56. {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/resources/conversation.py +0 -0
  57. {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/resources/workforce.py +0 -0
  58. {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/tracing/__init__.py +0 -0
  59. {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/tracing/ci_types.py +0 -0
  60. {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/tracing/ingest_client.py +0 -0
  61. {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx/util.py +0 -0
  62. {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx_python.egg-info/dependency_links.txt +0 -0
  63. {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx_python.egg-info/entry_points.txt +0 -0
  64. {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx_python.egg-info/not-zip-safe +0 -0
  65. {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx_python.egg-info/requires.txt +0 -0
  66. {agentx_python-0.6.26 → agentx_python-0.6.28}/agentx_python.egg-info/top_level.txt +0 -0
  67. {agentx_python-0.6.26 → agentx_python-0.6.28}/setup.cfg +0 -0
  68. {agentx_python-0.6.26 → agentx_python-0.6.28}/setup.py +0 -0
  69. {agentx_python-0.6.26 → agentx_python-0.6.28}/tests/test_integration.py +0 -0
  70. {agentx_python-0.6.26 → agentx_python-0.6.28}/tests/test_integrations.py +0 -0
  71. {agentx_python-0.6.26 → agentx_python-0.6.28}/tests/test_span_tree.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentx-python
3
- Version: 0.6.26
3
+ Version: 0.6.28
4
4
  Summary: Official Python SDK for AgentX (https://www.agentx.so/)
5
5
  Home-page: https://github.com/AgentX-ai/AgentX-python
6
6
  Author: Robin Wang and AgentX Team
@@ -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
- pass
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(self, method: str, path: str, timeout: int = 30, **kwargs) -> Any:
107
- url = f"{self._base_url}{path}"
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([0.0] + _RETRY_BACKOFF):
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 resp.status_code in _RETRYABLE_STATUS and attempt < _MAX_RETRIES - 1:
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(f"HTTP {resp.status_code}")
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(f"HTTP {resp.status_code}: {resp.text}")
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
- return self._request("POST", f"/runs/{run_id}/analyze", json=payload, timeout=30)
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
- data = self._request("GET", f"/runs/{run_id}/analyze-status")
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
- data = self._request("GET", f"/runs/{run_id}/report")
324
- return Report(**data)
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="completed",
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
- requires an enclosing ``with tracer.trace(...)`` block; a no-op (nothing to attach a child
741
- to) if called outside one.
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"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentx-python
3
- Version: 0.6.26
3
+ Version: 0.6.28
4
4
  Summary: Official Python SDK for AgentX (https://www.agentx.so/)
5
5
  Home-page: https://github.com/AgentX-ai/AgentX-python
6
6
  Author: Robin Wang and AgentX Team
@@ -64,4 +64,5 @@ agentx_python.egg-info/requires.txt
64
64
  agentx_python.egg-info/top_level.txt
65
65
  tests/test_integration.py
66
66
  tests/test_integrations.py
67
+ tests/test_selfhost_analysis_fallback.py
67
68
  tests/test_span_tree.py
@@ -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