agentx-python 0.6.38__tar.gz → 0.8.1__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.

Potentially problematic release.


This version of agentx-python might be problematic. Click here for more details.

Files changed (84) hide show
  1. {agentx_python-0.6.38 → agentx_python-0.8.1}/PKG-INFO +1 -1
  2. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx/evaluations/client.py +58 -0
  3. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx/evaluations/models.py +61 -0
  4. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx/evaluations/runner.py +14 -0
  5. agentx_python-0.8.1/agentx/testing.py +165 -0
  6. agentx_python-0.8.1/agentx/version.py +1 -0
  7. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx_python.egg-info/PKG-INFO +1 -1
  8. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx_python.egg-info/SOURCES.txt +4 -1
  9. {agentx_python-0.6.38 → agentx_python-0.8.1}/tests/test_integrations.py +57 -27
  10. agentx_python-0.8.1/tests/test_pairwise.py +119 -0
  11. agentx_python-0.8.1/tests/test_testing.py +49 -0
  12. agentx_python-0.6.38/agentx/version.py +0 -1
  13. {agentx_python-0.6.38 → agentx_python-0.8.1}/LICENSE +0 -0
  14. {agentx_python-0.6.38 → agentx_python-0.8.1}/README.md +0 -0
  15. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx/__init__.py +0 -0
  16. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx/agentx.py +0 -0
  17. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx/cli.py +0 -0
  18. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx/evaluations/__init__.py +0 -0
  19. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx/evaluations/_term.py +0 -0
  20. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx/evaluations/adapters/__init__.py +0 -0
  21. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx/evaluations/adapters/http_endpoint.py +0 -0
  22. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx/evaluations/adapters/precomputed.py +0 -0
  23. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx/evaluations/adapters/raw.py +0 -0
  24. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx/evaluations/datasets.py +0 -0
  25. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx/evaluations/evaluation_settings.py +0 -0
  26. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx/evaluations/prompts.py +0 -0
  27. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx/evaluations/reporting.py +0 -0
  28. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx/evaluations/results.py +0 -0
  29. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx/evaluations/tool_schemas.py +0 -0
  30. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx/evaluations/tracing.py +0 -0
  31. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx/exceptions.py +0 -0
  32. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx/export.py +0 -0
  33. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx/feedback.py +0 -0
  34. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx/integrations/__init__.py +0 -0
  35. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx/integrations/_traced_call.py +0 -0
  36. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx/integrations/anthropic.py +0 -0
  37. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx/integrations/autogen.py +0 -0
  38. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx/integrations/crewai.py +0 -0
  39. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx/integrations/databricks.py +0 -0
  40. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx/integrations/google_adk.py +0 -0
  41. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx/integrations/google_genai.py +0 -0
  42. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx/integrations/langchain.py +0 -0
  43. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx/integrations/litellm.py +0 -0
  44. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx/integrations/llamaindex.py +0 -0
  45. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx/integrations/moveworks.py +0 -0
  46. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx/integrations/openai.py +0 -0
  47. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx/integrations/openai_agents.py +0 -0
  48. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx/monitor/__init__.py +0 -0
  49. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx/monitor/agents.py +0 -0
  50. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx/monitor/client.py +0 -0
  51. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx/monitor/judge_scorers.py +0 -0
  52. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx/monitor/models.py +0 -0
  53. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx/monitor/online_evaluators.py +0 -0
  54. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx/monitor/patterns.py +0 -0
  55. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx/monitor/profile.py +0 -0
  56. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx/monitor/scorers.py +0 -0
  57. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx/monitor/sessions.py +0 -0
  58. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx/monitor/signals.py +0 -0
  59. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx/outcomes.py +0 -0
  60. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx/projects.py +0 -0
  61. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx/py.typed +0 -0
  62. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx/resources/__init__.py +0 -0
  63. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx/resources/agent.py +0 -0
  64. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx/resources/conversation.py +0 -0
  65. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx/resources/workforce.py +0 -0
  66. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx/traces.py +0 -0
  67. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx/tracing/__init__.py +0 -0
  68. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx/tracing/ci_types.py +0 -0
  69. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx/tracing/ingest_client.py +0 -0
  70. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx/tracing/tracer.py +0 -0
  71. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx/util.py +0 -0
  72. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx_python.egg-info/dependency_links.txt +0 -0
  73. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx_python.egg-info/entry_points.txt +0 -0
  74. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx_python.egg-info/not-zip-safe +0 -0
  75. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx_python.egg-info/requires.txt +0 -0
  76. {agentx_python-0.6.38 → agentx_python-0.8.1}/agentx_python.egg-info/top_level.txt +0 -0
  77. {agentx_python-0.6.38 → agentx_python-0.8.1}/setup.cfg +0 -0
  78. {agentx_python-0.6.38 → agentx_python-0.8.1}/setup.py +0 -0
  79. {agentx_python-0.6.38 → agentx_python-0.8.1}/tests/test_deep_dive_fixes.py +0 -0
  80. {agentx_python-0.6.38 → agentx_python-0.8.1}/tests/test_docs_match_sdk.py +0 -0
  81. {agentx_python-0.6.38 → agentx_python-0.8.1}/tests/test_integration.py +0 -0
  82. {agentx_python-0.6.38 → agentx_python-0.8.1}/tests/test_judge_scorers.py +0 -0
  83. {agentx_python-0.6.38 → agentx_python-0.8.1}/tests/test_selfhost_analysis_fallback.py +0 -0
  84. {agentx_python-0.6.38 → agentx_python-0.8.1}/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.38
3
+ Version: 0.8.1
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
@@ -17,6 +17,7 @@ from agentx.evaluations.models import (
17
17
  EvaluationSettings,
18
18
  EvaluationSubject,
19
19
  ModelInfo,
20
+ PairwiseComparison,
20
21
  Prompt,
21
22
  Report,
22
23
  )
@@ -541,6 +542,63 @@ class EvaluationsClient:
541
542
  "POST", f"/evaluate/prompts/{prompt_id}/versions", base=self._api_root, json=payload
542
543
  )
543
544
 
545
+ # ------------------------------------------------------------------
546
+ # Head-to-head (pairwise) judging. Rides the /evaluate dialect via _api_root(), same
547
+ # precedent as get_report and the prompt loop above.
548
+ # ------------------------------------------------------------------
549
+
550
+ def compare_pairwise(
551
+ self,
552
+ run_a_id: str,
553
+ run_b_id: str,
554
+ *,
555
+ criteria: Optional[str] = None,
556
+ judge_model: Optional[str] = None,
557
+ both_orders: bool = False,
558
+ ) -> PairwiseComparison:
559
+ """Ask a judge which of two runs answered each question better.
560
+
561
+ Both runs must be of the same dataset. Absolute ratings answer "is this above the bar";
562
+ this answers "did the change help", which is the question a diff between two runs is
563
+ actually asking. The judge sees the two answers as "Answer 1"/"Answer 2" with the order
564
+ alternating case by case, so it never learns which run is the candidate.
565
+
566
+ ``both_orders=True`` judges every pair twice with the sides swapped. It doubles the judge
567
+ cost and is the only real defense against position bias: a pair whose winner reverses is
568
+ recorded as a tie, and the batch reports its ``flip_rate``.
569
+
570
+ ``criteria`` and ``judge_model`` default to the dataset's own evaluation criteria and
571
+ judge model, so a head-to-head grades on the same terms a normal run of it does.
572
+ """
573
+ payload: Dict[str, Any] = {"runAId": run_a_id, "runBId": run_b_id}
574
+ if criteria:
575
+ payload["criteria"] = criteria
576
+ if judge_model:
577
+ payload["judgeModel"] = judge_model
578
+ if both_orders:
579
+ payload["bothOrders"] = True
580
+ response = self._request("POST", "/evaluate/runs/pairwise", json=payload, base=self._api_root)
581
+ return PairwiseComparison(**response["comparison"])
582
+
583
+ def get_pairwise(self, batch_id: str) -> PairwiseComparison:
584
+ """Read back a stored head-to-head by its batch id."""
585
+ response = self._request("GET", f"/evaluate/runs/pairwise/{batch_id}", base=self._api_root)
586
+ return PairwiseComparison(**response["comparison"])
587
+
588
+ def list_pairwise(
589
+ self, *, run_a_id: Optional[str] = None, run_b_id: Optional[str] = None
590
+ ) -> List[Dict[str, Any]]:
591
+ """Summaries of past head-to-heads, newest first, optionally narrowed to one run."""
592
+ params: Dict[str, Any] = {}
593
+ if run_a_id:
594
+ params["runAId"] = run_a_id
595
+ if run_b_id:
596
+ params["runBId"] = run_b_id
597
+ response = self._request(
598
+ "GET", "/evaluate/runs/pairwise", params=params or None, base=self._api_root
599
+ )
600
+ return response.get("comparisons", [])
601
+
544
602
  # ------------------------------------------------------------------
545
603
  # Tool schema registry (same version-scoped propose/publish loop as prompts)
546
604
  # ------------------------------------------------------------------
@@ -742,3 +742,64 @@ class AnalysisStatus(BaseModel):
742
742
  @property
743
743
  def is_terminal(self) -> bool:
744
744
  return self.status in ("completed", "partially_failed", "failed")
745
+
746
+
747
+ class PairwiseSummary(BaseModel):
748
+ """Batch-level verdict of a head-to-head comparison (``compare_pairwise``).
749
+
750
+ ``winner`` is "a", "b", or "tie" - a dead heat is reported as a tie rather than broken
751
+ arbitrarily. ``flip_rate`` is only populated for a ``both_orders=True`` comparison: it is the
752
+ share of cases whose winner reversed when the two answers were swapped, which is position
753
+ bias rather than quality. A high flip rate means the batch is inconclusive, so it is reported
754
+ instead of being folded away."""
755
+
756
+ total: int = 0
757
+ a_wins: int = Field(default=0, alias="aWins")
758
+ b_wins: int = Field(default=0, alias="bWins")
759
+ ties: int = 0
760
+ winner: str = "tie"
761
+ flip_rate: Optional[float] = Field(default=None, alias="flipRate")
762
+
763
+ class Config:
764
+ populate_by_name = True
765
+ extra = "ignore"
766
+
767
+
768
+ class PairwiseCase(BaseModel):
769
+ """One question's verdict. ``presented_first`` records which run's answer the judge read
770
+ first, because that is the confound pairwise judging exists to control for."""
771
+
772
+ id: Optional[str] = Field(default=None, alias="_id")
773
+ question_index: Optional[int] = Field(default=None, alias="questionIndex")
774
+ query: Optional[str] = None
775
+ winner: str = "tie"
776
+ presented_first: str = Field(default="a", alias="presentedFirst")
777
+ flipped: bool = False
778
+ justification: Optional[str] = None
779
+ judge_model: Optional[str] = Field(default=None, alias="judgeModel")
780
+
781
+ class Config:
782
+ populate_by_name = True
783
+ extra = "ignore"
784
+
785
+
786
+ class PairwiseComparison(BaseModel):
787
+ """A full head-to-head between two runs of the same dataset.
788
+
789
+ ``skipped`` names the cases that could not be judged (one side produced no answer, or the
790
+ batch hit the server's per-comparison cap) with the reason - a comparison that quietly
791
+ dropped half the dataset would read as a clean sweep."""
792
+
793
+ batch_id: str = Field(alias="batchId")
794
+ run_a_id: str = Field(alias="runAId")
795
+ run_b_id: str = Field(alias="runBId")
796
+ both_orders: bool = Field(default=False, alias="bothOrders")
797
+ judge_model: Optional[str] = Field(default=None, alias="judgeModel")
798
+ summary: PairwiseSummary = Field(default_factory=PairwiseSummary)
799
+ cases: List[PairwiseCase] = Field(default_factory=list)
800
+ skipped: List[Dict[str, Any]] = Field(default_factory=list)
801
+ created_at: Optional[str] = Field(default=None, alias="createdAt")
802
+
803
+ class Config:
804
+ populate_by_name = True
805
+ extra = "ignore"
@@ -446,6 +446,20 @@ class EvaluationsRunner:
446
446
  EvaluationsClient.simulate_conversation for parameters."""
447
447
  return self._client.simulate_conversation(**kwargs)
448
448
 
449
+ def compare_pairwise(self, run_a_id: str, run_b_id: str, **kwargs):
450
+ """Head-to-head judging between two runs of the same dataset - "which answer is better"
451
+ rather than two absolute scores. See EvaluationsClient.compare_pairwise for the options,
452
+ and agentx.testing.assert_pairwise for the pytest-side check."""
453
+ return self._client.compare_pairwise(run_a_id, run_b_id, **kwargs)
454
+
455
+ def get_pairwise(self, batch_id: str):
456
+ """Read back a stored head-to-head by its batch id."""
457
+ return self._client.get_pairwise(batch_id)
458
+
459
+ def list_pairwise(self, **kwargs) -> list:
460
+ """Summaries of past head-to-heads, newest first, optionally narrowed to one run."""
461
+ return self._client.list_pairwise(**kwargs)
462
+
449
463
  def get_run(self, run_id: str) -> dict:
450
464
  """Run summary + per-result rows by id, without needing the EvaluationRunContext that
451
465
  created it (e.g. from a separate process)."""
@@ -0,0 +1,165 @@
1
+ """pytest-friendly assertions over evaluation runs.
2
+
3
+ The DeepEval-style dev loop (``assert_test`` inside a pytest suite) on top of AgentX's existing
4
+ run + CI-gate primitives: run the evaluation however you like, then make the test fail with a
5
+ readable verdict when quality drops. No plugin registration needed - it's a plain function that
6
+ raises ``AssertionError``, so it works in any test runner and any CI.
7
+
8
+ Usage::
9
+
10
+ from agentx import AgentX
11
+ from agentx.testing import assert_evaluation
12
+
13
+ def test_support_agent_quality():
14
+ client = AgentX.from_env()
15
+ report = (
16
+ client.evaluations
17
+ .run(dataset_id=DATASET_ID, scorer_id=SCORER_ID, subject=SUBJECT)
18
+ .execute(my_agent)
19
+ .finalize()
20
+ )
21
+ assert_evaluation(report, min_rating=7.0, no_regression=True)
22
+
23
+ The check rides the engine's CI gate, so every pytest verdict is also recorded in the
24
+ dashboard's gate history (CI Gates tab) with ``caller="pytest"`` - a red test and the
25
+ dashboard's gate row are the same event, not two systems drifting apart.
26
+ """
27
+
28
+ from typing import Any, List, Optional
29
+
30
+
31
+ class EvaluationAssertionError(AssertionError):
32
+ """Raised when an evaluation run fails its quality checks.
33
+
34
+ Subclasses ``AssertionError`` so pytest renders it as a plain test failure; carries the
35
+ ``gate`` result for programmatic inspection in test hooks.
36
+ """
37
+
38
+ def __init__(self, message: str, gate: Any = None):
39
+ super().__init__(message)
40
+ self.gate = gate
41
+
42
+
43
+ def _format_failures(gate: Any) -> str:
44
+ lines: List[str] = []
45
+ checks = getattr(gate, "checks", None) or []
46
+ for check in checks:
47
+ get = check.get if isinstance(check, dict) else lambda k, d=None: getattr(check, k, d)
48
+ status = "PASS" if get("passed") else "FAIL"
49
+ lines.append(f" [{status}] {get('name', 'check')}: {get('detail', '')}")
50
+ average = getattr(gate, "average_rating", None)
51
+ if average is not None:
52
+ lines.append(f" average rating: {average}")
53
+ return "\n".join(lines) if lines else f" gate: {gate!r}"
54
+
55
+
56
+ def assert_evaluation(
57
+ report: Any,
58
+ *,
59
+ min_rating: Optional[float] = None,
60
+ no_regression: bool = False,
61
+ tolerance: Optional[float] = None,
62
+ caller: str = "pytest",
63
+ ) -> Any:
64
+ """Assert a finalized evaluation run meets its quality floor.
65
+
66
+ ``report`` is the finalized :class:`~agentx.evaluations.runner.EvaluationRunContext`
67
+ returned by ``.execute(...).finalize()`` (or any object exposing the same ``.gate()``).
68
+
69
+ - ``min_rating`` - fail when the run's average judge rating is below this floor (0-10).
70
+ - ``no_regression`` - fail when the average dropped more than ``tolerance`` (default 0.5;
71
+ judge scores are noisy) below the dataset's previous completed run.
72
+
73
+ At least one check is required. Returns the ``GateResult`` on success; raises
74
+ :class:`EvaluationAssertionError` with a per-check verdict on failure.
75
+ """
76
+ if min_rating is None and not no_regression:
77
+ raise ValueError("assert_evaluation needs at least one check: min_rating and/or no_regression=True")
78
+ gate = report.gate(
79
+ fail_under=min_rating,
80
+ no_regression=no_regression,
81
+ tolerance=tolerance,
82
+ caller=caller,
83
+ )
84
+ if getattr(gate, "passed", False):
85
+ return gate
86
+ run_id = getattr(report, "run_id", None) or getattr(getattr(report, "_run", None), "run_id", "?")
87
+ raise EvaluationAssertionError(
88
+ f"Evaluation run {run_id} failed its quality gate:\n{_format_failures(gate)}",
89
+ gate=gate,
90
+ )
91
+
92
+
93
+ def _format_pairwise(comparison: Any) -> str:
94
+ summary = getattr(comparison, "summary", None)
95
+ lines: List[str] = []
96
+ if summary is not None:
97
+ flip = getattr(summary, "flip_rate", None)
98
+ lines.append(
99
+ f" A won {getattr(summary, 'a_wins', 0)}, B won {getattr(summary, 'b_wins', 0)}, "
100
+ f"{getattr(summary, 'ties', 0)} tied, out of {getattr(summary, 'total', 0)}"
101
+ + (f" (flip rate {flip})" if flip is not None else "")
102
+ )
103
+ for case in getattr(comparison, "cases", None) or []:
104
+ if getattr(case, "winner", None) == "b":
105
+ query = (getattr(case, "query", None) or "").strip()
106
+ lines.append(f" lost: {query[:80]} - {(getattr(case, 'justification', None) or '')[:120]}")
107
+ return "\n".join(lines) if lines else f" comparison: {comparison!r}"
108
+
109
+
110
+ def assert_pairwise(
111
+ comparison: Any,
112
+ *,
113
+ must_win: bool = False,
114
+ max_losses: Optional[int] = None,
115
+ max_flip_rate: Optional[float] = None,
116
+ ) -> Any:
117
+ """Assert a head-to-head comparison went the candidate's way.
118
+
119
+ ``comparison`` is what ``client.evaluations.compare_pairwise(a, b)`` returns; run A is the
120
+ candidate and run B is the baseline it has to beat.
121
+
122
+ - ``must_win`` - fail unless A won more cases than B. A tie fails: "no worse than before" is
123
+ not the same claim as "better", and a change that cannot win its own comparison has not
124
+ earned a green test.
125
+ - ``max_losses`` - fail when A lost more than this many individual cases, even if it won
126
+ overall. This is the check that catches a change that lifts the average by improving easy
127
+ cases while breaking hard ones.
128
+ - ``max_flip_rate`` - fail when too many verdicts reversed with the presentation order. That
129
+ is position bias rather than quality, and it means the comparison itself is inconclusive,
130
+ so treating it as a pass would be worse than a red test. Only meaningful for a comparison
131
+ run with ``both_orders=True``; a comparison without it has no flip rate and this check is
132
+ skipped rather than quietly passing.
133
+
134
+ At least one check is required. Returns the comparison on success; raises
135
+ :class:`EvaluationAssertionError` naming the cases that lost.
136
+ """
137
+ if not must_win and max_losses is None and max_flip_rate is None:
138
+ raise ValueError(
139
+ "assert_pairwise needs at least one check: must_win, max_losses, and/or max_flip_rate"
140
+ )
141
+ summary = getattr(comparison, "summary", None)
142
+ if summary is None:
143
+ raise ValueError("assert_pairwise expects the result of compare_pairwise()")
144
+
145
+ failures: List[str] = []
146
+ a_wins = getattr(summary, "a_wins", 0)
147
+ b_wins = getattr(summary, "b_wins", 0)
148
+ if must_win and a_wins <= b_wins:
149
+ failures.append(f"run A did not win ({a_wins} vs {b_wins})")
150
+ if max_losses is not None and b_wins > max_losses:
151
+ failures.append(f"run A lost {b_wins} cases, more than the {max_losses} allowed")
152
+ flip_rate = getattr(summary, "flip_rate", None)
153
+ if max_flip_rate is not None and flip_rate is not None and flip_rate > max_flip_rate:
154
+ failures.append(
155
+ f"verdicts flipped on {flip_rate:.0%} of cases with the presentation order, above the "
156
+ f"{max_flip_rate:.0%} allowed - this comparison is inconclusive, not a pass"
157
+ )
158
+
159
+ if not failures:
160
+ return comparison
161
+ batch_id = getattr(comparison, "batch_id", "?")
162
+ raise EvaluationAssertionError(
163
+ f"Head-to-head {batch_id} failed: {'; '.join(failures)}\n{_format_pairwise(comparison)}",
164
+ gate=comparison,
165
+ )
@@ -0,0 +1 @@
1
+ VERSION = "0.8.1"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentx-python
3
- Version: 0.6.38
3
+ Version: 0.8.1
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
@@ -10,6 +10,7 @@ agentx/feedback.py
10
10
  agentx/outcomes.py
11
11
  agentx/projects.py
12
12
  agentx/py.typed
13
+ agentx/testing.py
13
14
  agentx/traces.py
14
15
  agentx/util.py
15
16
  agentx/version.py
@@ -74,5 +75,7 @@ tests/test_docs_match_sdk.py
74
75
  tests/test_integration.py
75
76
  tests/test_integrations.py
76
77
  tests/test_judge_scorers.py
78
+ tests/test_pairwise.py
77
79
  tests/test_selfhost_analysis_fallback.py
78
- tests/test_span_tree.py
80
+ tests/test_span_tree.py
81
+ tests/test_testing.py
@@ -640,22 +640,32 @@ def test_crewai_captures_real_per_task_timing_via_event_bus():
640
640
 
641
641
  return FakeCrewOutput(raw="final output", tasks_output=[output1, output2])
642
642
 
643
- tracer = make_tracer()
643
+ # Boundary-mocked rather than make_tracer(): each task is a real child span now (9d45dd1
644
+ # replaced the synthetic performance_summary step list), and mocking tracer._send would
645
+ # bypass the very _send/_dispatch/child_span chain that builds them. Same idiom as
646
+ # tests/test_span_tree.py.
647
+ tracer = Tracer(ingest_client=MagicMock())
644
648
  observer = AgentXCrewObserver(tracer, name="my-crew")
645
649
 
646
650
  result = observer.kickoff(FakeCrew(), inputs={"topic": "AI"})
647
651
 
648
652
  assert result.raw == "final output"
649
- tracer._send.assert_called_once()
650
- _, kwargs = tracer._send.call_args
651
- steps = kwargs["performance_summary"]["execution_steps"]
652
- assert len(steps) == 2
653
- assert steps[0]["name"] == "Research topic"
654
- assert steps[1]["name"] == "Write summary"
655
- # Real timing, not an even split task 1 slept ~3x longer than task 2.
656
- assert steps[0]["duration_ms"] > steps[1]["duration_ms"] * 1.5
657
- assert steps[0]["output"] == "research done"
658
- assert steps[1]["output"] == "summary done"
653
+ wires = [call.args[0] for call in tracer._client.enqueue.call_args_list]
654
+ assert len(wires) == 3
655
+ research, summary, root = wires
656
+ assert root["name"] == "my-crew"
657
+ assert root["output"] == "final output"
658
+
659
+ # Each task is its own child span under the crew's root, carrying its own real duration.
660
+ assert research["name"] == "Research topic"
661
+ assert summary["name"] == "Write summary"
662
+ assert research["parent_span_id"] == root["span_id"]
663
+ assert summary["parent_span_id"] == root["span_id"]
664
+ assert research["output"] == "research done"
665
+ assert summary["output"] == "summary done"
666
+ # The point of the test: real timing, not an even split — task 1 slept ~3x longer than
667
+ # task 2, and the old "divide latency evenly" approximation reported them identical.
668
+ assert research["latency_ms"] > summary["latency_ms"] * 1.5
659
669
 
660
670
 
661
671
  def test_crewai_falls_back_to_even_split_without_event_bus():
@@ -930,6 +940,10 @@ def test_llamaindex_llm_error_is_captured():
930
940
  def test_autogen_agent_run_traces_text_reply():
931
941
  """Drives a real AssistantAgent.run() via AutoGen's own ReplayChatCompletionClient (no network/API keys)."""
932
942
  pytest.importorskip("autogen_agentchat")
943
+ # autogen-ext is a separate distribution and is NOT part of the "autogen" extra -
944
+ # ReplayChatCompletionClient below is a test double that lives there, so guard it too
945
+ # or this fails with ModuleNotFoundError instead of skipping.
946
+ pytest.importorskip("autogen_ext")
933
947
  from autogen_agentchat.agents import AssistantAgent
934
948
  from autogen_ext.models.replay import ReplayChatCompletionClient
935
949
 
@@ -938,25 +952,33 @@ def test_autogen_agent_run_traces_text_reply():
938
952
  model_client = ReplayChatCompletionClient(["Hello from AutoGen!"])
939
953
  agent = AssistantAgent("assistant", model_client=model_client)
940
954
 
941
- tracer = make_tracer()
955
+ # Boundary-mocked, not make_tracer(): the agent's turn is a real child span since
956
+ # 9d45dd1, and mocking tracer._send would bypass the chain that builds it.
957
+ tracer = Tracer(ingest_client=MagicMock())
942
958
  observer = AgentXAutoGenObserver(tracer, name="my-agent")
943
959
 
944
960
  result = asyncio.run(observer.run(agent, task="Say hello"))
945
961
 
946
962
  assert result.messages[-1].content == "Hello from AutoGen!"
947
- tracer._send.assert_called_once()
948
- _, kwargs = tracer._send.call_args
949
- assert kwargs["input"] == "Say hello"
950
- assert kwargs["output"] == "Hello from AutoGen!"
951
- assert kwargs["input_tokens"] == 22
952
- assert kwargs["output_tokens"] == 3
953
- steps = kwargs["performance_summary"]["execution_steps"]
954
- assert len(steps) == 1
955
- assert steps[0]["output"] == "Hello from AutoGen!"
963
+ wires = [call.args[0] for call in tracer._client.enqueue.call_args_list]
964
+ assert len(wires) == 2
965
+ step, root = wires
966
+ assert root["input"] == "Say hello"
967
+ assert root["output"] == "Hello from AutoGen!"
968
+ assert root["input_tokens"] == 22
969
+ assert root["output_tokens"] == 3
970
+ # The turn itself, as its own child span rather than a performance_summary step.
971
+ assert step["name"] == "assistant"
972
+ assert step["output"] == "Hello from AutoGen!"
973
+ assert step["parent_span_id"] == root["span_id"]
956
974
 
957
975
 
958
976
  def test_autogen_agent_run_traces_tool_call():
959
977
  pytest.importorskip("autogen_agentchat")
978
+ # autogen-ext is a separate distribution and is NOT part of the "autogen" extra -
979
+ # ReplayChatCompletionClient below is a test double that lives there, so guard it too
980
+ # or this fails with ModuleNotFoundError instead of skipping.
981
+ pytest.importorskip("autogen_ext")
960
982
  import json
961
983
 
962
984
  from autogen_agentchat.agents import AssistantAgent
@@ -986,16 +1008,24 @@ def test_autogen_agent_run_traces_tool_call():
986
1008
  )
987
1009
  agent = AssistantAgent("assistant", model_client=model_client, tools=[tool])
988
1010
 
989
- tracer = make_tracer()
1011
+ tracer = Tracer(ingest_client=MagicMock())
990
1012
  observer = AgentXAutoGenObserver(tracer, name="my-agent")
991
1013
 
992
1014
  asyncio.run(observer.run(agent, task="What is the weather in NYC?"))
993
1015
 
994
- tracer._send.assert_called_once()
995
- _, kwargs = tracer._send.call_args
996
- perf = kwargs["performance_summary"]
997
- assert len(perf["tool_calls"]) == 1
998
- tool_call = perf["tool_calls"][0]
1016
+ wires = [call.args[0] for call in tracer._client.enqueue.call_args_list]
1017
+ assert len(wires) == 2
1018
+ child, root = wires
1019
+ # The tool call is a real child span...
1020
+ assert child["name"] == "get_weather"
1021
+ assert "NYC" in child["input"]
1022
+ assert child["output"] == "sunny in NYC"
1023
+ assert child["parent_span_id"] == root["span_id"]
1024
+ # ...and is mirrored onto the ROOT's flat tool_calls, which is what the engine's built-in
1025
+ # "Tool failure" check and the dashboard's Tool quality column read. Same deliberate
1026
+ # dual-write as trace_tool_call() - see test_span_tree.py.
1027
+ assert len(root["tool_calls"]) == 1
1028
+ tool_call = root["tool_calls"][0]
999
1029
  assert tool_call["name"] == "get_weather"
1000
1030
  assert "NYC" in tool_call["input"]
1001
1031
  assert tool_call["output"] == "sunny in NYC"
@@ -0,0 +1,119 @@
1
+ """Head-to-head judging: the client's request shape and the pytest assertion over the result."""
2
+
3
+ import pytest
4
+
5
+ from agentx.evaluations.models import PairwiseComparison
6
+ from agentx.testing import EvaluationAssertionError, assert_pairwise
7
+
8
+
9
+ def comparison(**over) -> PairwiseComparison:
10
+ payload = {
11
+ "batchId": "batch-1",
12
+ "runAId": "run-candidate",
13
+ "runBId": "run-baseline",
14
+ "bothOrders": False,
15
+ "judgeModel": "gpt-5.6-luna",
16
+ "summary": {"total": 3, "aWins": 2, "bWins": 1, "ties": 0, "winner": "a", "flipRate": None},
17
+ "cases": [
18
+ {
19
+ "questionIndex": 2,
20
+ "query": "Who pays return shipping?",
21
+ "winner": "b",
22
+ "presentedFirst": "a",
23
+ "justification": "Answer 2 names both cases explicitly.",
24
+ }
25
+ ],
26
+ "skipped": [],
27
+ }
28
+ payload.update(over)
29
+ return PairwiseComparison(**payload)
30
+
31
+
32
+ class FakeClient:
33
+ """Captures the request instead of sending it - the wire shape is the contract with the
34
+ engine, and camelCase is the convention it has to keep."""
35
+
36
+ # The pairwise routes live on the /evaluate dialect, reached through this property.
37
+ _api_root = "https://engine.example/api/v1"
38
+
39
+ def __init__(self, response=None):
40
+ self.calls = []
41
+ self._response = response or {"comparison": comparison().model_dump(by_alias=True)}
42
+
43
+ def _request(self, method, path, **kwargs):
44
+ self.calls.append((method, path, kwargs))
45
+ return self._response
46
+
47
+
48
+ def test_compare_pairwise_sends_camelcase_and_omits_unset_options():
49
+ from agentx.evaluations.client import EvaluationsClient
50
+
51
+ client = FakeClient()
52
+ result = EvaluationsClient.compare_pairwise(client, "run-candidate", "run-baseline")
53
+
54
+ method, path, kwargs = client.calls[0]
55
+ assert (method, path) == ("POST", "/evaluate/runs/pairwise")
56
+ # Defaults are the server's to choose; the SDK does not invent a criteria string or a
57
+ # judge model, and does not send bothOrders unless the caller asked for it.
58
+ assert kwargs["json"] == {"runAId": "run-candidate", "runBId": "run-baseline"}
59
+ assert result.summary.a_wins == 2
60
+ assert result.cases[0].presented_first == "a"
61
+
62
+
63
+ def test_compare_pairwise_forwards_the_options_it_is_given():
64
+ from agentx.evaluations.client import EvaluationsClient
65
+
66
+ client = FakeClient()
67
+ EvaluationsClient.compare_pairwise(
68
+ client, "a", "b", criteria="Which is more concise?", judge_model="gpt-5.6-luna", both_orders=True
69
+ )
70
+ assert client.calls[0][2]["json"] == {
71
+ "runAId": "a",
72
+ "runBId": "b",
73
+ "criteria": "Which is more concise?",
74
+ "judgeModel": "gpt-5.6-luna",
75
+ "bothOrders": True,
76
+ }
77
+
78
+
79
+ def test_assert_pairwise_passes_a_clear_win():
80
+ result = assert_pairwise(comparison(), must_win=True, max_losses=1)
81
+ assert result.summary.winner == "a"
82
+
83
+
84
+ def test_a_tie_is_not_a_win():
85
+ tied = comparison(summary={"total": 2, "aWins": 1, "bWins": 1, "ties": 0, "winner": "tie", "flipRate": None})
86
+ with pytest.raises(EvaluationAssertionError) as excinfo:
87
+ assert_pairwise(tied, must_win=True)
88
+ assert "did not win" in str(excinfo.value)
89
+
90
+
91
+ def test_max_losses_catches_a_win_that_broke_hard_cases():
92
+ # Wins overall, but lost more individual cases than the caller tolerates.
93
+ lossy = comparison(summary={"total": 10, "aWins": 5, "bWins": 4, "ties": 1, "winner": "a", "flipRate": None})
94
+ with pytest.raises(EvaluationAssertionError) as excinfo:
95
+ assert_pairwise(lossy, max_losses=2)
96
+ message = str(excinfo.value)
97
+ assert "lost 4 cases" in message
98
+ # The failure names the case that lost, so the test output is actionable on its own.
99
+ assert "Who pays return shipping?" in message
100
+
101
+
102
+ def test_a_high_flip_rate_fails_instead_of_passing_on_position_bias():
103
+ biased = comparison(
104
+ bothOrders=True,
105
+ summary={"total": 4, "aWins": 3, "bWins": 1, "ties": 0, "winner": "a", "flipRate": 0.5},
106
+ )
107
+ with pytest.raises(EvaluationAssertionError) as excinfo:
108
+ assert_pairwise(biased, max_flip_rate=0.2)
109
+ assert "inconclusive" in str(excinfo.value)
110
+
111
+
112
+ def test_flip_rate_check_is_skipped_when_both_orders_was_not_run():
113
+ # No flip rate exists to check, so this must not silently fail or silently pass a made-up 0.
114
+ assert_pairwise(comparison(), max_flip_rate=0.0)
115
+
116
+
117
+ def test_requires_at_least_one_check():
118
+ with pytest.raises(ValueError):
119
+ assert_pairwise(comparison())
@@ -0,0 +1,49 @@
1
+ """agentx.testing.assert_evaluation - the pytest-native quality gate wrapper."""
2
+
3
+ import pytest
4
+
5
+ from agentx.evaluations.runner import GateResult
6
+ from agentx.testing import EvaluationAssertionError, assert_evaluation
7
+
8
+
9
+ class FakeReport:
10
+ def __init__(self, gate_payload):
11
+ self.run_id = "run-123"
12
+ self.gate_kwargs = None
13
+ self._payload = gate_payload
14
+
15
+ def gate(self, **kwargs):
16
+ self.gate_kwargs = kwargs
17
+ return GateResult(self._payload)
18
+
19
+
20
+ def test_passing_gate_returns_result_and_forwards_checks():
21
+ report = FakeReport({"passed": True, "averageRating": 8.2, "checks": [{"name": "floor", "passed": True}]})
22
+ gate = assert_evaluation(report, min_rating=7.0, no_regression=True, tolerance=0.3)
23
+ assert gate.passed is True
24
+ assert report.gate_kwargs == {"fail_under": 7.0, "no_regression": True, "tolerance": 0.3, "caller": "pytest"}
25
+
26
+
27
+ def test_failing_gate_raises_assertion_error_with_verdict():
28
+ report = FakeReport({
29
+ "passed": False,
30
+ "averageRating": 5.1,
31
+ "checks": [
32
+ {"name": "floor", "passed": False, "detail": "average 5.1 below fail_under 7"},
33
+ {"name": "regression", "passed": True, "detail": "no baseline"},
34
+ ],
35
+ })
36
+ with pytest.raises(EvaluationAssertionError) as excinfo:
37
+ assert_evaluation(report, min_rating=7.0)
38
+ message = str(excinfo.value)
39
+ assert "run-123" in message
40
+ assert "[FAIL] floor" in message
41
+ assert "average 5.1 below fail_under 7" in message
42
+ # AssertionError subclass, so pytest treats it as a normal test failure.
43
+ assert isinstance(excinfo.value, AssertionError)
44
+ assert excinfo.value.gate.average_rating == 5.1
45
+
46
+
47
+ def test_requires_at_least_one_check():
48
+ with pytest.raises(ValueError):
49
+ assert_evaluation(FakeReport({"passed": True}))
@@ -1 +0,0 @@
1
- VERSION = "0.6.38"
File without changes
File without changes
File without changes
File without changes