agentx-python 0.8.20__tar.gz → 0.8.22__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 (99) hide show
  1. {agentx_python-0.8.20/agentx_python.egg-info → agentx_python-0.8.22}/PKG-INFO +1 -1
  2. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/__init__.py +4 -0
  3. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/agentx.py +35 -5
  4. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/evaluations/client.py +52 -27
  5. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/evaluations/datasets.py +2 -1
  6. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/evaluations/evaluation_settings.py +2 -1
  7. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/evaluations/models.py +31 -2
  8. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/evaluations/prompts.py +3 -3
  9. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/evaluations/runner.py +82 -35
  10. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/exceptions.py +19 -1
  11. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/export.py +2 -1
  12. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/feedback.py +2 -1
  13. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/integrations/autogen.py +4 -0
  14. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/integrations/crewai.py +69 -28
  15. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/integrations/google_adk.py +39 -3
  16. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/integrations/langchain.py +91 -53
  17. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/integrations/llamaindex.py +85 -46
  18. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/integrations/openai_agents.py +21 -2
  19. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/monitor/client.py +34 -15
  20. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/monitor/improvement_groups.py +2 -1
  21. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/monitor/judge_scorers.py +11 -2
  22. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/monitor/models.py +7 -0
  23. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/monitor/profile.py +1 -1
  24. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/monitor/scorer_groups.py +3 -1
  25. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/monitor/scorers.py +2 -1
  26. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/outcomes.py +2 -1
  27. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/projects.py +2 -1
  28. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/traces.py +2 -1
  29. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/tracing/ingest_client.py +31 -5
  30. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/tracing/tracer.py +89 -45
  31. agentx_python-0.8.22/agentx/util.py +29 -0
  32. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/version.py +2 -2
  33. {agentx_python-0.8.20 → agentx_python-0.8.22/agentx_python.egg-info}/PKG-INFO +1 -1
  34. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx_python.egg-info/SOURCES.txt +3 -1
  35. {agentx_python-0.8.20 → agentx_python-0.8.22}/tests/test_deep_dive_fixes.py +45 -0
  36. agentx_python-0.8.22/tests/test_error_taxonomy.py +86 -0
  37. {agentx_python-0.8.20 → agentx_python-0.8.22}/tests/test_integrations.py +1 -1
  38. {agentx_python-0.8.20 → agentx_python-0.8.22}/tests/test_judge_scorers.py +47 -0
  39. {agentx_python-0.8.20 → agentx_python-0.8.22}/tests/test_runner_features.py +89 -0
  40. {agentx_python-0.8.20 → agentx_python-0.8.22}/tests/test_selfhost_analysis_fallback.py +25 -1
  41. {agentx_python-0.8.20 → agentx_python-0.8.22}/tests/test_span_tree.py +284 -8
  42. agentx_python-0.8.22/tests/test_wire_models.py +83 -0
  43. agentx_python-0.8.20/agentx/util.py +0 -20
  44. {agentx_python-0.8.20 → agentx_python-0.8.22}/LICENSE +0 -0
  45. {agentx_python-0.8.20 → agentx_python-0.8.22}/README.md +0 -0
  46. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/cli.py +0 -0
  47. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/evaluations/__init__.py +0 -0
  48. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/evaluations/_term.py +0 -0
  49. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/evaluations/adapters/__init__.py +0 -0
  50. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/evaluations/adapters/http_endpoint.py +0 -0
  51. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/evaluations/adapters/precomputed.py +0 -0
  52. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/evaluations/adapters/raw.py +0 -0
  53. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/evaluations/reporting.py +0 -0
  54. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/evaluations/results.py +0 -0
  55. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/evaluations/tool_schemas.py +0 -0
  56. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/evaluations/tracing.py +0 -0
  57. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/integrations/__init__.py +0 -0
  58. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/integrations/_traced_call.py +0 -0
  59. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/integrations/anthropic.py +0 -0
  60. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/integrations/databricks.py +0 -0
  61. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/integrations/google_genai.py +0 -0
  62. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/integrations/litellm.py +0 -0
  63. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/integrations/moveworks.py +0 -0
  64. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/integrations/openai.py +0 -0
  65. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/monitor/__init__.py +0 -0
  66. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/monitor/agents.py +0 -0
  67. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/monitor/online_evaluators.py +0 -0
  68. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/monitor/patterns.py +0 -0
  69. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/monitor/review_queue.py +0 -0
  70. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/monitor/rules.py +0 -0
  71. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/monitor/sessions.py +0 -0
  72. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/monitor/signals.py +0 -0
  73. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/py.typed +0 -0
  74. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/resources/__init__.py +0 -0
  75. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/resources/agent.py +0 -0
  76. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/resources/conversation.py +0 -0
  77. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/resources/workforce.py +0 -0
  78. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/testing.py +0 -0
  79. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/tracing/__init__.py +0 -0
  80. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/tracing/ci_types.py +0 -0
  81. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/tracing/eval_scope.py +0 -0
  82. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx/tracing/framework_detect.py +0 -0
  83. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx_python.egg-info/dependency_links.txt +0 -0
  84. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx_python.egg-info/entry_points.txt +0 -0
  85. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx_python.egg-info/not-zip-safe +0 -0
  86. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx_python.egg-info/requires.txt +0 -0
  87. {agentx_python-0.8.20 → agentx_python-0.8.22}/agentx_python.egg-info/top_level.txt +0 -0
  88. {agentx_python-0.8.20 → agentx_python-0.8.22}/setup.cfg +0 -0
  89. {agentx_python-0.8.20 → agentx_python-0.8.22}/setup.py +0 -0
  90. {agentx_python-0.8.20 → agentx_python-0.8.22}/tests/test_cli_launcher.py +0 -0
  91. {agentx_python-0.8.20 → agentx_python-0.8.22}/tests/test_docs_match_sdk.py +0 -0
  92. {agentx_python-0.8.20 → agentx_python-0.8.22}/tests/test_eval_scope.py +0 -0
  93. {agentx_python-0.8.20 → agentx_python-0.8.22}/tests/test_framework_detect.py +0 -0
  94. {agentx_python-0.8.20 → agentx_python-0.8.22}/tests/test_integration.py +0 -0
  95. {agentx_python-0.8.20 → agentx_python-0.8.22}/tests/test_multi_judge.py +0 -0
  96. {agentx_python-0.8.20 → agentx_python-0.8.22}/tests/test_pairwise.py +0 -0
  97. {agentx_python-0.8.20 → agentx_python-0.8.22}/tests/test_review_queue.py +0 -0
  98. {agentx_python-0.8.20 → agentx_python-0.8.22}/tests/test_selfhost_compat.py +0 -0
  99. {agentx_python-0.8.20 → agentx_python-0.8.22}/tests/test_testing.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentx-python
3
- Version: 0.8.20
3
+ Version: 0.8.22
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
@@ -5,6 +5,8 @@ from agentx.version import VERSION
5
5
  from agentx.exceptions import (
6
6
  AgentXError,
7
7
  AgentXAuthError,
8
+ AgentXValidationError,
9
+ AgentXConnectionError,
8
10
  AgentXAPIError,
9
11
  DatasetNotFound,
10
12
  CINotEnabled,
@@ -21,6 +23,8 @@ __all__ = [
21
23
  "AgentX",
22
24
  "AgentXError",
23
25
  "AgentXAuthError",
26
+ "AgentXValidationError",
27
+ "AgentXConnectionError",
24
28
  "AgentXAPIError",
25
29
  "DatasetNotFound",
26
30
  "CINotEnabled",
@@ -3,7 +3,7 @@ import requests
3
3
  import os
4
4
  import logging
5
5
 
6
- from agentx.util import get_headers, api_base
6
+ from agentx.util import get_headers, api_base, normalize_base
7
7
  from agentx.resources.agent import Agent
8
8
  from agentx.resources.workforce import Workforce
9
9
 
@@ -16,16 +16,23 @@ class AgentX:
16
16
  base_url: Optional[str] = None,
17
17
  workspace_id: Optional[str] = None,
18
18
  ):
19
+ # The api_key is NOT written back into os.environ (it used to be): every sub-client
20
+ # below receives it explicitly, and mutating process-global state from a constructor
21
+ # re-pointed unrelated code - the same leak the base_url write below had (deep-dive
22
+ # round 3, bug #1). Static flows that still read the env (AgentX.list_workforces,
23
+ # bare get_headers()) now require the caller to set AGENTX_API_KEY themselves.
19
24
  self.api_key = api_key or os.getenv("AGENTX_API_KEY")
20
- if self.api_key and not os.getenv("AGENTX_API_KEY"):
21
- os.environ["AGENTX_API_KEY"] = self.api_key
22
25
 
23
26
  # base_url overrides AGENTX_API_BASE_URL env var (and the SDK default). It is
24
27
  # deliberately NOT written back into os.environ: the constructor used to do that, which
25
28
  # made the last-constructed client silently re-point every other client in the process
26
29
  # (deep-dive round 3, bug #1). Each sub-client below receives this value explicitly and
27
- # captures it at construction instead.
30
+ # captures it at construction instead. Normalized (trailing slash and the
31
+ # /custom-agent-evaluations suffix stripped) so an evaluations-shaped URL works for
32
+ # every sub-client, not just evaluations.
28
33
  self.base_url = base_url or os.getenv("AGENTX_API_BASE_URL")
34
+ if self.base_url:
35
+ self.base_url = normalize_base(self.base_url)
29
36
 
30
37
  self.workspace_id = workspace_id or os.getenv("AGENTX_WORKSPACE_ID")
31
38
 
@@ -91,6 +98,27 @@ class AgentX:
91
98
  workspace_id=self.workspace_id,
92
99
  )
93
100
  self.tracer = Tracer(_ingest_client)
101
+ self._ingest_client = _ingest_client
102
+
103
+ # ------------------------------------------------------------------
104
+ # Lifecycle
105
+ # ------------------------------------------------------------------
106
+
107
+ def close(self, timeout: float = 5.0) -> bool:
108
+ """Flush queued traces and stop the tracer's background ingest worker.
109
+
110
+ Returns ``True`` when everything drained before ``timeout`` seconds elapsed. Optional -
111
+ an ``atexit`` hook already flushes queued traces on interpreter shutdown - but a
112
+ long-running service that tears clients down mid-process should call it (or use the
113
+ client as a context manager) so worker threads don't accumulate.
114
+ """
115
+ return self._ingest_client.close(timeout)
116
+
117
+ def __enter__(self) -> "AgentX":
118
+ return self
119
+
120
+ def __exit__(self, exc_type, exc_val, tb) -> None:
121
+ self.close()
94
122
 
95
123
  @classmethod
96
124
  def from_env(cls) -> "AgentX":
@@ -129,7 +157,9 @@ class AgentX:
129
157
 
130
158
  @staticmethod
131
159
  def list_workforces() -> List["Workforce"]:
132
- """List all workforces/teams."""
160
+ """List all workforces/teams. Static, so it reads AGENTX_API_KEY from the environment
161
+ directly - the constructor no longer writes ``api_key`` into os.environ, so set the
162
+ env var yourself before calling this."""
133
163
  url = f"{api_base()}/access/teams"
134
164
  response = requests.get(url, headers=get_headers())
135
165
  if response.status_code == 200:
@@ -24,7 +24,12 @@ from agentx.evaluations.models import (
24
24
 
25
25
  logger = logging.getLogger(__name__)
26
26
 
27
- from agentx.util import _DEFAULT_API_BASE as _UTIL_API_BASE
27
+ from agentx.util import _DEFAULT_API_BASE as _UTIL_API_BASE, normalize_base
28
+
29
+ # The canonical error classes (agentx.exceptions) are raised - and re-exported here for
30
+ # compat with code that imported them from this module - so `except agentx.AgentXAuthError`
31
+ # works whichever client raised.
32
+ from agentx.exceptions import AgentXError, AgentXAuthError, AgentXValidationError
28
33
 
29
34
  _DEFAULT_BASE_URL = f"{_UTIL_API_BASE}/custom-agent-evaluations"
30
35
  SDK_NAME = "agentx-python"
@@ -42,7 +47,7 @@ _SELF_HOST_ANALYZE_TIMEOUT = 1800
42
47
  _SELF_HOST_SCORING_TIMEOUT = 900
43
48
 
44
49
 
45
- class AgentXEvaluationsError(Exception):
50
+ class AgentXEvaluationsError(AgentXError):
46
51
  """An evaluations API call failed.
47
52
 
48
53
  ``status_code`` carries the HTTP status when the failure came from a response rather
@@ -55,14 +60,6 @@ class AgentXEvaluationsError(Exception):
55
60
  self.status_code = status_code
56
61
 
57
62
 
58
- class AgentXAuthError(AgentXEvaluationsError):
59
- pass
60
-
61
-
62
- class AgentXValidationError(AgentXEvaluationsError):
63
- pass
64
-
65
-
66
63
  class EvaluationSubmissionError(AgentXEvaluationsError):
67
64
  """A result batch could not be submitted (after one retry). The run is left unfinalized;
68
65
  re-running execute() on the same context resumes past already-submitted cases."""
@@ -96,13 +93,10 @@ class EvaluationsClient:
96
93
  # whatever workspace the API key's user defaults to, not the one the caller intended.
97
94
  self._workspace_id = workspace_id
98
95
  # Priority: constructor arg > env var > SDK default
99
- # Always append /custom-agent-evaluations so users only need to provide /api/v1
100
- _api_base = (
101
- base_url or os.getenv("AGENTX_API_BASE_URL", _UTIL_API_BASE)
102
- ).rstrip("/")
103
- if not _api_base.endswith("/custom-agent-evaluations"):
104
- _api_base = f"{_api_base}/custom-agent-evaluations"
105
- self._base_url = _api_base
96
+ # normalize_base strips a trailing slash and any /custom-agent-evaluations suffix,
97
+ # then the suffix is appended - users only need to provide /api/v1 either way.
98
+ _api_base = normalize_base(base_url or os.getenv("AGENTX_API_BASE_URL", _UTIL_API_BASE))
99
+ self._base_url = f"{_api_base}/custom-agent-evaluations"
106
100
  # None until an analysis call tells us which engine this is; see _api_root.
107
101
  self._analysis_on_dashboard_router: Optional[bool] = None
108
102
  self._session = requests.Session()
@@ -184,13 +178,16 @@ class EvaluationsClient:
184
178
  continue
185
179
 
186
180
  if resp.status_code == 401:
187
- raise AgentXAuthError("Invalid or missing API key")
181
+ raise AgentXAuthError("Invalid or missing API key", status_code=401)
188
182
  if resp.status_code == 422:
189
183
  raise AgentXValidationError(resp.text)
184
+ # Gate on the schedule itself so HTTP-status retries walk the SAME full backoff
185
+ # schedule connection errors do - the old `attempt < _MAX_RETRIES - 1` gate left
186
+ # the schedule's last entry unreachable for HTTP retries (ingest_client precedent).
190
187
  if (
191
188
  resp.status_code in _RETRYABLE_STATUS
192
189
  and retry
193
- and attempt < _MAX_RETRIES - 1
190
+ and attempt < len(schedule) - 1
194
191
  ):
195
192
  logger.debug(
196
193
  "Retryable status %d (attempt %d)", resp.status_code, attempt + 1
@@ -207,6 +204,12 @@ class EvaluationsClient:
207
204
  return resp.json()
208
205
  except Exception:
209
206
  return resp.text
207
+ # A timeout keeps its type: runner._flush_batch catches requests.Timeout specifically
208
+ # (the engine may still be scoring the batch - a blind retry double-bills every judge
209
+ # call), and wrapping it in AgentXEvaluationsError here made that guard unreachable.
210
+ # Applies to retry=False calls too, where the single attempt lands straight here.
211
+ if isinstance(last_exc, requests.Timeout):
212
+ raise last_exc
210
213
  raise AgentXEvaluationsError(f"Request failed after retries: {last_exc}")
211
214
 
212
215
  # ------------------------------------------------------------------
@@ -227,7 +230,9 @@ class EvaluationsClient:
227
230
  # ------------------------------------------------------------------
228
231
 
229
232
  def create_dataset(self, payload: dict) -> Dataset:
230
- data = self._request("POST", "/datasets", json=self._with_workspace(payload))
233
+ # Server-side write: a timeout after the dataset row was created would be
234
+ # retried into a duplicate dataset, so no transport retry.
235
+ data = self._request("POST", "/datasets", json=self._with_workspace(payload), retry=False)
231
236
  return Dataset(**data)
232
237
 
233
238
  def delete_dataset(self, dataset_id: str) -> None:
@@ -255,8 +260,9 @@ class EvaluationsClient:
255
260
  # ------------------------------------------------------------------
256
261
 
257
262
  def create_evaluation_settings(self, payload: dict) -> EvaluationSettings:
263
+ # Server-side write - no transport retry (see init_run's comment).
258
264
  data = self._request(
259
- "POST", "/evaluation-settings", json=self._with_workspace(payload)
265
+ "POST", "/evaluation-settings", json=self._with_workspace(payload), retry=False
260
266
  )
261
267
  return EvaluationSettings(**data)
262
268
 
@@ -281,12 +287,14 @@ class EvaluationsClient:
281
287
 
282
288
  # ------------------------------------------------------------------
283
289
  # Prompt registry endpoints - see agentx.evaluations.prompts.PromptClient for the concept
284
- # (the external-agent analog to native autotune). Deliberately read-mostly: no publish here,
285
- # a new version only ever comes from the dashboard's human-approved propose/publish flow.
290
+ # (the external-agent analog to native autotune). propose_prompt never publishes;
291
+ # publish_prompt_version below IS the explicit approval step - call it only after a human
292
+ # reviewed the proposal.
286
293
  # ------------------------------------------------------------------
287
294
 
288
295
  def create_prompt(self, payload: dict) -> Prompt:
289
- data = self._request("POST", "/prompts", json=self._with_workspace(payload))
296
+ # Server-side write - no transport retry (see init_run's comment).
297
+ data = self._request("POST", "/prompts", json=self._with_workspace(payload), retry=False)
290
298
  return Prompt(**data)
291
299
 
292
300
  def list_prompts(self) -> List[Prompt]:
@@ -322,7 +330,9 @@ class EvaluationsClient:
322
330
  alias and keeps working. ``split`` records the named case subset this run covers.
323
331
  ``additional_scorer_ids`` (self-host): extra judge scorers that each pass their own
324
332
  verdict on every result from the same single agent execution - verdicts land in each
325
- result row's ``judgeScorerResults`` and the run's ``scorerBreakdown``."""
333
+ result row's ``judgeScorerResults`` and the run's ``scorerBreakdown``. When
334
+ ``scorer_group_id`` is set, the engine nulls ``additionalScorerIds`` on the run too -
335
+ the group is the whole grading story, not a layer on top of extra scorers."""
326
336
  from agentx.version import VERSION
327
337
 
328
338
  grader_id = _resolve_scorer_id(scorer_id, evaluation_settings_id)
@@ -343,7 +353,8 @@ class EvaluationsClient:
343
353
  if additional_scorer_ids:
344
354
  payload["additionalScorerIds"] = additional_scorer_ids
345
355
  # Scorer group grading (self-host): the group's weighted 0-10 aggregate fills the rating
346
- # column and member verdicts land per row. Mutually exclusive with scorer_id (group wins).
356
+ # column and member verdicts land per row. Mutually exclusive with scorer_id (group
357
+ # wins), and the engine also nulls additionalScorerIds when a group grades the run.
347
358
  if scorer_group_id:
348
359
  payload["scorerGroupId"] = scorer_group_id
349
360
  if split:
@@ -408,7 +419,10 @@ class EvaluationsClient:
408
419
  # scorer's own per-result verdicts. Unknown names are a hard 400 from the engine.
409
420
  if scorer:
410
421
  params["scorer"] = scorer
411
- return self._request("GET", f"/runs/{run_id}/gate", params=params)
422
+ # record=True is a server-side write despite the GET verb (it persists a gate-history
423
+ # row): a timeout after the row was stored would be retried into a duplicate verdict,
424
+ # so no transport retry - same precedent as init_run.
425
+ return self._request("GET", f"/runs/{run_id}/gate", params=params, retry=not record)
412
426
 
413
427
  def analyze_run(
414
428
  self,
@@ -483,6 +497,17 @@ class EvaluationsClient:
483
497
  return self._report_from_dashboard(run_id)
484
498
 
485
499
  def get_missing_results(self, run_id: str) -> List[Dict[str, Any]]:
500
+ """Deprecated: on self-host the route's response body has no top-level list, so this
501
+ always returns ``[]``. Use :meth:`get_submitted_keys` - the same route's
502
+ ``submittedKeys`` - to find out what a run still needs."""
503
+ import warnings
504
+
505
+ warnings.warn(
506
+ "get_missing_results() always returns [] on self-host - use get_submitted_keys() "
507
+ "to resume a run instead.",
508
+ DeprecationWarning,
509
+ stacklevel=2,
510
+ )
486
511
  data = self._request("GET", f"/runs/{run_id}/missing-results")
487
512
  return data if isinstance(data, list) else data.get("missing", [])
488
513
 
@@ -81,7 +81,8 @@ class DatasetBuilder:
81
81
  self._payload["codeScorers"] = [
82
82
  {
83
83
  "id": scorer.get("id") or _uuid.uuid4().hex[:12],
84
- "name": scorer["name"],
84
+ # Name may be omitted - the engine defaults it, so don't KeyError here.
85
+ "name": scorer.get("name"),
85
86
  "code": scorer["code"],
86
87
  "enabled": scorer.get("enabled", True),
87
88
  }
@@ -64,7 +64,8 @@ class EvaluationSettingsBuilder:
64
64
  if rouge_score:
65
65
  self._payload["rougeScore"] = {"enabled": True}
66
66
  # Sovereignty & Portability - the models to compare when this config runs
67
- # (use client.evaluations.list_models() to discover valid ids).
67
+ # (use client.evaluations.list_models() to discover valid ids). Self-host: accepted
68
+ # on the wire but not acted on by the engine (same caveat as DatasetBuilder's).
68
69
  if sovereignty_models:
69
70
  self._payload["sovereigntyIndex"] = {
70
71
  "enabled": True,
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from typing import Any, Dict, List, Literal, Optional, Union
4
- from pydantic import AliasChoices, BaseModel, Field, model_validator
4
+ from pydantic import AliasChoices, BaseModel, Field, field_validator, model_validator
5
5
 
6
6
  # ---------------------------------------------------------------------------
7
7
  # Observable trace
@@ -78,6 +78,17 @@ class Dataset(BaseModel):
78
78
  # Custom code scorers attached to this dataset - [{ id, name, code, enabled }]. Retrievable,
79
79
  # so a fetched dataset round-trips them (import_dataset copies them to the new dataset).
80
80
  code_scorers: Optional[List[Dict[str, Any]]] = Field(default=None, alias="codeScorers")
81
+ # Grading config carried on the dataset itself - similarity metric toggles (each a
82
+ # {"enabled": bool, ...} object on the wire), LLM-as-judge overrides, and the raw
83
+ # sovereigntyIndex object. Modeled so a fetched Dataset round-trips them: extra="ignore"
84
+ # used to silently drop all of these on read, and import_dataset lost them on the copy.
85
+ vector_similarity: Optional[Any] = Field(default=None, alias="vectorSimilarity")
86
+ jaccard_similarity: Optional[Any] = Field(default=None, alias="jaccardSimilarity")
87
+ bleu_score: Optional[Any] = Field(default=None, alias="bleuScore")
88
+ rouge_score: Optional[Any] = Field(default=None, alias="rougeScore")
89
+ judge_prompt: Optional[str] = Field(default=None, alias="judgePrompt")
90
+ judge_model: Optional[str] = Field(default=None, alias="judgeModel")
91
+ sovereignty_index: Optional[Dict[str, Any]] = Field(default=None, alias="sovereigntyIndex")
81
92
  status: str = "published"
82
93
  version_id: Optional[str] = Field(default=None, alias="versionId")
83
94
  # Sovereignty & Portability - models selected to compare on this dataset.
@@ -248,6 +259,10 @@ class LiveStatistics(BaseModel):
248
259
  min_rating: Optional[float] = Field(default=None, alias="minRating")
249
260
  max_rating: Optional[float] = Field(default=None, alias="maxRating")
250
261
  rated_count: int = Field(default=0, alias="ratedCount")
262
+ # Rows the judge could not score / rows submitted with an error - the difference between
263
+ # "everything rated 8" and "half the run never got a verdict".
264
+ skipped_count: int = Field(default=0, alias="skippedCount")
265
+ failed_count: int = Field(default=0, alias="failedCount")
251
266
 
252
267
  class Config:
253
268
  populate_by_name = True
@@ -426,7 +441,12 @@ class RunResultRow(BaseModel):
426
441
  question_index: Optional[int] = Field(default=None, alias="questionIndex")
427
442
  run_number: Optional[int] = Field(default=None, alias="runNumber")
428
443
  question_text: Optional[str] = Field(default=None, alias="questionText")
429
- response: Optional[str] = None
444
+ # The engine sends the agent's answer as an `output` OBJECT ({"text": ...}), not a
445
+ # `response` string - accept both spellings and lift the dict's text (see the
446
+ # validator below), so row.response actually populates on self-host.
447
+ response: Optional[str] = Field(
448
+ default=None, validation_alias=AliasChoices("response", "output")
449
+ )
430
450
  trace_id: Optional[str] = Field(default=None, alias="traceId")
431
451
  latency_ms: Optional[float] = Field(default=None, alias="latencyMs")
432
452
  input_tokens: Optional[int] = Field(default=None, alias="inputTokens")
@@ -448,6 +468,15 @@ class RunResultRow(BaseModel):
448
468
  populate_by_name = True
449
469
  extra = "ignore"
450
470
 
471
+ @field_validator("response", mode="before")
472
+ @classmethod
473
+ def _lift_output_text(cls, value: Any) -> Any:
474
+ # The `output` alias delivers the wire's whole output object - keep the declared
475
+ # Optional[str] by lifting its text field.
476
+ if isinstance(value, dict):
477
+ return value.get("text")
478
+ return value
479
+
451
480
  @classmethod
452
481
  def from_wire(cls, wire: Dict[str, Any]) -> "RunResultRow":
453
482
  row = cls.model_validate(wire)
@@ -17,9 +17,9 @@ class PromptClient:
17
17
  existing version-comparison view (``client.evaluations`` run comparisons on a dataset) can
18
18
  tell you which published version actually scored higher.
19
19
 
20
- Deliberately read-mostly from here: there is no ``publish`` on this client. A prompt only
21
- gets a new version through the dashboard's human-approved propose/publish flow, so a
22
- rewritten prompt never reaches your running agent without someone explicitly approving it.
20
+ ``propose()`` never publishes anything; ``publish_version()`` IS the explicit approval
21
+ step - call it only after a human reviewed the proposal, since a published version is
22
+ what your running agent pulls as its live prompt.
23
23
 
24
24
  Example::
25
25
 
@@ -11,7 +11,11 @@ from typing import Any, Callable, Dict, Iterator, List, Optional, Set, Union
11
11
  from agentx.evaluations.adapters.raw import RawCallableAdapter
12
12
  from agentx.evaluations.adapters.precomputed import PrecomputedAdapter
13
13
  from agentx.evaluations.adapters.http_endpoint import HttpEndpointAdapter
14
- from agentx.evaluations.client import EvaluationsClient, EvaluationSubmissionError
14
+ from agentx.evaluations.client import (
15
+ AgentXEvaluationsError,
16
+ EvaluationsClient,
17
+ EvaluationSubmissionError,
18
+ )
15
19
  from agentx.evaluations.models import (
16
20
  AnalysisStatus,
17
21
  Dataset,
@@ -227,6 +231,7 @@ class EvaluationRunContext:
227
231
  if concurrency > 1:
228
232
  import concurrent.futures
229
233
  import contextvars
234
+ from collections import deque
230
235
 
231
236
  def in_scope(case: EvaluationCase) -> EvaluationResult:
232
237
  # ContextVars (the eval-run scope) do not cross thread boundaries on their own -
@@ -240,45 +245,68 @@ class EvaluationRunContext:
240
245
  if _idem_key(self._run.run_id, case.case_id, case.run_number) not in already_done
241
246
  ]
242
247
  executor = concurrent.futures.ThreadPoolExecutor(max_workers=concurrency)
243
- # map() yields in submission order, so batching/submission below stays deterministic.
244
- mapped = executor.map(in_scope, pending)
245
248
 
246
- def ordered() -> "Iterator[EvaluationResult]":
249
+ def bounded() -> "Iterator[EvaluationResult]":
250
+ # Bounded submit loop instead of executor.map(): map() dispatches EVERY case
251
+ # up front, so a fail-fast flush failure (EvaluationSubmissionError below)
252
+ # still paid for the whole rest of the run in agent calls. Keep at most
253
+ # `concurrency` cases in flight, topping up as results are consumed; yields
254
+ # stay in submission order so batching below is deterministic. On teardown
255
+ # (an exception in the consuming loop closes this generator) whatever is
256
+ # queued but unstarted is cancelled.
257
+ import itertools
258
+
259
+ case_iter = iter(pending)
260
+ in_flight: "deque[concurrent.futures.Future]" = deque()
247
261
  try:
248
- yield from mapped
262
+ for case in itertools.islice(case_iter, concurrency):
263
+ in_flight.append(executor.submit(in_scope, case))
264
+ while in_flight:
265
+ result = in_flight.popleft().result()
266
+ next_case = next(case_iter, None)
267
+ if next_case is not None:
268
+ in_flight.append(executor.submit(in_scope, next_case))
269
+ yield result
249
270
  finally:
250
- executor.shutdown(wait=True)
271
+ executor.shutdown(wait=False, cancel_futures=True)
251
272
 
252
- results_iter = ordered()
273
+ results_iter = bounded()
253
274
  else:
254
275
  results_iter = None # sequential path below produces inline
255
276
 
256
- for idx, case in enumerate(cases, start=1):
257
- idem_key = _idem_key(self._run.run_id, case.case_id, case.run_number)
258
-
259
- if idem_key in already_done:
260
- logger.debug("Skipping already-submitted case: %s", idem_key)
261
- _print_progress(idx, total, case, skipped=True)
262
- continue
263
-
264
- result = next(results_iter) if results_iter is not None else produce(case)
265
- result.idempotency_key = idem_key
266
- # Tag the result with the case's model so the server can group it into
267
- # the Sovereignty & Portability matrix (the callable may also set it).
268
- if case.model:
269
- meta = dict(result.metadata or {})
270
- meta.setdefault("model", case.model)
271
- result.metadata = meta
272
- result = EvaluationResult(
273
- **{**result.model_dump(), "idempotencyKey": idem_key}
274
- )
275
- self._results.append(result)
276
- batch.append(result)
277
- _print_progress(idx, total, case, result=result)
277
+ try:
278
+ for idx, case in enumerate(cases, start=1):
279
+ idem_key = _idem_key(self._run.run_id, case.case_id, case.run_number)
280
+
281
+ if idem_key in already_done:
282
+ logger.debug("Skipping already-submitted case: %s", idem_key)
283
+ _print_progress(idx, total, case, skipped=True)
284
+ continue
285
+
286
+ result = next(results_iter) if results_iter is not None else produce(case)
287
+ result.idempotency_key = idem_key
288
+ # Tag the result with the case's model so the server can group it into
289
+ # the Sovereignty & Portability matrix (the callable may also set it).
290
+ if case.model:
291
+ meta = dict(result.metadata or {})
292
+ meta.setdefault("model", case.model)
293
+ result.metadata = meta
294
+ result = EvaluationResult(
295
+ **{**result.model_dump(), "idempotencyKey": idem_key}
296
+ )
297
+ self._results.append(result)
298
+ batch.append(result)
299
+ _print_progress(idx, total, case, result=result)
278
300
 
279
- if len(batch) >= max_batch:
280
- self._flush_batch(batch)
281
- batch = []
301
+ if len(batch) >= max_batch:
302
+ self._flush_batch(batch)
303
+ batch = []
304
+ finally:
305
+ # Deterministic teardown: a flush failure mid-run must stop the in-flight agent
306
+ # dispatch NOW (bounded()'s finally cancels queued cases), not whenever the
307
+ # generator happens to be garbage-collected.
308
+ if results_iter is not None:
309
+ results_iter.close()
282
310
 
283
311
  if batch:
284
312
  self._flush_batch(batch)
@@ -333,15 +361,31 @@ class EvaluationRunContext:
333
361
  so a re-execute() after a crash skips (and never re-pays for) finished cases."""
334
362
  try:
335
363
  return set(self._client.get_submitted_keys(self._run.run_id))
336
- except Exception:
337
- # Older engines without the route: no resume, identical to the historical behavior.
338
- return set()
364
+ except AgentXEvaluationsError as exc:
365
+ if exc.status_code == 404:
366
+ # Older engines without the route: no resume, identical to the historical
367
+ # behavior. ONLY the 404 qualifies - a transient 502/timeout here used to be
368
+ # swallowed too, and an empty resume set silently re-runs (and re-bills)
369
+ # every already-finished case.
370
+ return set()
371
+ _say(f" {red('✗')} Could not fetch already-submitted keys: {dim(str(exc))}")
372
+ logger.error(
373
+ "Resume-key fetch for run %s failed (%s) - refusing to re-run the whole run "
374
+ "blind; retry execute() once the engine is reachable",
375
+ self._run.run_id,
376
+ exc,
377
+ )
378
+ raise
339
379
 
340
380
  # ------------------------------------------------------------------
341
381
  # Step 2: finalize
342
382
  # ------------------------------------------------------------------
343
383
 
344
384
  def finalize(self) -> "EvaluationRunContext":
385
+ """Mark the run completed server-side. A failed finalize is raised, not swallowed
386
+ (same fail-loud posture as _flush_batch): it leaves the run in_progress - a state a
387
+ CI pipeline MUST treat as a failure, since gates and baselines only consider
388
+ completed runs."""
345
389
  _say()
346
390
  with Spinner("Finalizing - submitting results"):
347
391
  try:
@@ -353,6 +397,7 @@ class EvaluationRunContext:
353
397
  except Exception as exc:
354
398
  _say(f" {red('✗')} Finalize failed: {dim(str(exc))}")
355
399
  logger.error("Finalize failed: %s", exc)
400
+ raise
356
401
  return self
357
402
 
358
403
  def gate(
@@ -460,6 +505,8 @@ class EvaluationRunContext:
460
505
 
461
506
  Args:
462
507
  mode: "auto" (default), "sync", or "batch" - how item scoring executes server-side.
508
+ Hosted-only: self-host runs the analysis synchronously regardless; see the
509
+ response's mode field for what actually ran.
463
510
  quality_mode: "quality_first" or "balanced" - how many items get a second/third judge.
464
511
  judges: 1-3 model ids from ``client.evaluations.list_models()``, e.g.
465
512
  ``["gpt-5.6-luna", "claude-opus-4-8"]``. Omit to let the engine score with its
@@ -12,7 +12,25 @@ class AgentXError(Exception):
12
12
 
13
13
 
14
14
  class AgentXAuthError(AgentXError):
15
- """Invalid or missing API key."""
15
+ """Invalid or missing API key.
16
+
17
+ Canonical across every sub-client (evaluations, monitor, ...) - ``except
18
+ agentx.AgentXAuthError`` catches an auth failure no matter which client raised it.
19
+ ``status_code`` carries the HTTP status when known (401/403), ``None`` otherwise.
20
+ """
21
+
22
+ def __init__(self, message: str, status_code: int | None = None) -> None:
23
+ super().__init__(message)
24
+ self.status_code = status_code
25
+
26
+
27
+ class AgentXValidationError(AgentXError):
28
+ """The API rejected the request as invalid (HTTP 422). Canonical across every
29
+ sub-client, same as :class:`AgentXAuthError`."""
30
+
31
+ def __init__(self, message: str, status_code: int | None = 422) -> None:
32
+ super().__init__(message)
33
+ self.status_code = status_code
16
34
 
17
35
 
18
36
  class AgentXConnectionError(AgentXError):
@@ -8,11 +8,12 @@ from typing import Any, Dict, Iterator, List, Optional
8
8
  import requests
9
9
 
10
10
  from agentx.util import api_base, get_headers
11
+ from agentx.exceptions import AgentXError
11
12
 
12
13
  logger = logging.getLogger(__name__)
13
14
 
14
15
 
15
- class AgentXExportError(Exception):
16
+ class AgentXExportError(AgentXError):
16
17
  pass
17
18
 
18
19
 
@@ -6,11 +6,12 @@ from typing import Any, Dict, Optional
6
6
  import requests
7
7
 
8
8
  from agentx.util import api_base, get_headers
9
+ from agentx.exceptions import AgentXError
9
10
 
10
11
  logger = logging.getLogger(__name__)
11
12
 
12
13
 
13
- class AgentXFeedbackError(Exception):
14
+ class AgentXFeedbackError(AgentXError):
14
15
  pass
15
16
 
16
17
 
@@ -195,6 +195,10 @@ class AgentXAutoGenObserver:
195
195
  "output": text,
196
196
  "inputTokenSize": input_tokens,
197
197
  "outputTokenSize": out_tokens,
198
+ # A named source is an agent turn in the team's trajectory, not a bare
199
+ # model call - stated so _merge_child_run doesn't stamp it "llm" (the
200
+ # crewai task-step precedent).
201
+ **({"kind": "agent"} if source else {}),
198
202
  })
199
203
 
200
204
  if text: