agentx-python 0.8.21__tar.gz → 0.8.23__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.21/agentx_python.egg-info → agentx_python-0.8.23}/PKG-INFO +8 -1
  2. {agentx_python-0.8.21 → agentx_python-0.8.23}/README.md +7 -0
  3. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/__init__.py +4 -0
  4. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/agentx.py +35 -5
  5. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/evaluations/client.py +48 -23
  6. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/evaluations/evaluation_settings.py +14 -1
  7. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/evaluations/models.py +31 -2
  8. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/evaluations/reporting.py +1 -1
  9. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/evaluations/results.py +10 -0
  10. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/evaluations/runner.py +149 -40
  11. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/exceptions.py +19 -1
  12. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/export.py +2 -1
  13. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/feedback.py +2 -1
  14. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/integrations/_traced_call.py +5 -1
  15. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/integrations/autogen.py +8 -1
  16. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/integrations/crewai.py +70 -29
  17. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/integrations/google_adk.py +33 -1
  18. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/integrations/langchain.py +91 -53
  19. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/integrations/llamaindex.py +91 -47
  20. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/integrations/openai_agents.py +18 -2
  21. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/monitor/client.py +27 -13
  22. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/monitor/improvement_groups.py +2 -1
  23. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/monitor/judge_scorers.py +24 -6
  24. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/monitor/models.py +7 -5
  25. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/monitor/scorer_groups.py +3 -1
  26. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/monitor/scorers.py +2 -1
  27. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/outcomes.py +2 -1
  28. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/projects.py +2 -1
  29. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/traces.py +2 -1
  30. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/tracing/ingest_client.py +48 -9
  31. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/tracing/tracer.py +49 -19
  32. agentx_python-0.8.23/agentx/util.py +29 -0
  33. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/version.py +2 -2
  34. {agentx_python-0.8.21 → agentx_python-0.8.23/agentx_python.egg-info}/PKG-INFO +8 -1
  35. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx_python.egg-info/SOURCES.txt +3 -1
  36. {agentx_python-0.8.21 → agentx_python-0.8.23}/tests/test_deep_dive_fixes.py +45 -0
  37. {agentx_python-0.8.21 → agentx_python-0.8.23}/tests/test_docs_match_sdk.py +15 -0
  38. agentx_python-0.8.23/tests/test_error_taxonomy.py +86 -0
  39. {agentx_python-0.8.21 → agentx_python-0.8.23}/tests/test_integrations.py +1 -1
  40. {agentx_python-0.8.21 → agentx_python-0.8.23}/tests/test_judge_scorers.py +80 -0
  41. {agentx_python-0.8.21 → agentx_python-0.8.23}/tests/test_runner_features.py +73 -0
  42. {agentx_python-0.8.21 → agentx_python-0.8.23}/tests/test_selfhost_analysis_fallback.py +21 -0
  43. {agentx_python-0.8.21 → agentx_python-0.8.23}/tests/test_span_tree.py +232 -3
  44. agentx_python-0.8.23/tests/test_wire_models.py +83 -0
  45. agentx_python-0.8.21/agentx/util.py +0 -20
  46. {agentx_python-0.8.21 → agentx_python-0.8.23}/LICENSE +0 -0
  47. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/cli.py +0 -0
  48. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/evaluations/__init__.py +0 -0
  49. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/evaluations/_term.py +0 -0
  50. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/evaluations/adapters/__init__.py +0 -0
  51. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/evaluations/adapters/http_endpoint.py +0 -0
  52. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/evaluations/adapters/precomputed.py +0 -0
  53. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/evaluations/adapters/raw.py +0 -0
  54. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/evaluations/datasets.py +0 -0
  55. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/evaluations/prompts.py +0 -0
  56. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/evaluations/tool_schemas.py +0 -0
  57. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/evaluations/tracing.py +0 -0
  58. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/integrations/__init__.py +0 -0
  59. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/integrations/anthropic.py +0 -0
  60. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/integrations/databricks.py +0 -0
  61. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/integrations/google_genai.py +0 -0
  62. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/integrations/litellm.py +0 -0
  63. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/integrations/moveworks.py +0 -0
  64. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/integrations/openai.py +0 -0
  65. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/monitor/__init__.py +0 -0
  66. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/monitor/agents.py +0 -0
  67. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/monitor/online_evaluators.py +0 -0
  68. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/monitor/patterns.py +0 -0
  69. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/monitor/profile.py +0 -0
  70. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/monitor/review_queue.py +0 -0
  71. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/monitor/rules.py +0 -0
  72. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/monitor/sessions.py +0 -0
  73. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/monitor/signals.py +0 -0
  74. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/py.typed +0 -0
  75. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/resources/__init__.py +0 -0
  76. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/resources/agent.py +0 -0
  77. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/resources/conversation.py +0 -0
  78. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/resources/workforce.py +0 -0
  79. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/testing.py +0 -0
  80. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/tracing/__init__.py +0 -0
  81. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/tracing/ci_types.py +0 -0
  82. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/tracing/eval_scope.py +0 -0
  83. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx/tracing/framework_detect.py +0 -0
  84. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx_python.egg-info/dependency_links.txt +0 -0
  85. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx_python.egg-info/entry_points.txt +0 -0
  86. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx_python.egg-info/not-zip-safe +0 -0
  87. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx_python.egg-info/requires.txt +0 -0
  88. {agentx_python-0.8.21 → agentx_python-0.8.23}/agentx_python.egg-info/top_level.txt +0 -0
  89. {agentx_python-0.8.21 → agentx_python-0.8.23}/setup.cfg +0 -0
  90. {agentx_python-0.8.21 → agentx_python-0.8.23}/setup.py +0 -0
  91. {agentx_python-0.8.21 → agentx_python-0.8.23}/tests/test_cli_launcher.py +0 -0
  92. {agentx_python-0.8.21 → agentx_python-0.8.23}/tests/test_eval_scope.py +0 -0
  93. {agentx_python-0.8.21 → agentx_python-0.8.23}/tests/test_framework_detect.py +0 -0
  94. {agentx_python-0.8.21 → agentx_python-0.8.23}/tests/test_integration.py +0 -0
  95. {agentx_python-0.8.21 → agentx_python-0.8.23}/tests/test_multi_judge.py +0 -0
  96. {agentx_python-0.8.21 → agentx_python-0.8.23}/tests/test_pairwise.py +0 -0
  97. {agentx_python-0.8.21 → agentx_python-0.8.23}/tests/test_review_queue.py +0 -0
  98. {agentx_python-0.8.21 → agentx_python-0.8.23}/tests/test_selfhost_compat.py +0 -0
  99. {agentx_python-0.8.21 → agentx_python-0.8.23}/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.21
3
+ Version: 0.8.23
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
@@ -255,6 +255,13 @@ extra:
255
255
  | LlamaIndex | `pip install "agentx-python[llamaindex]"` | `AgentXLlamaIndexHandler` |
256
256
  | AutoGen | `pip install "agentx-python[autogen]"` | `AgentXAutoGenObserver` |
257
257
 
258
+ > **Warning: pick ONE instrumentation layer per LLM call.** Do not combine
259
+ > `AgentXCallbackHandler` (or any framework integration) with a patched provider client
260
+ > (`patch_openai_client`, `patch_anthropic_client`, `patch_genai_client`) on the same code
261
+ > path. A patched call that runs outside an active span emits its own root trace, so every
262
+ > LLM call the framework already traces gets a duplicate trace - and its cost is counted
263
+ > twice.
264
+
258
265
  Two more platforms are covered by **pull importers** rather than in-process hooks, each with its
259
266
  own CLI: `agentx-moveworks` (Moveworks Data API sync, no extra needed) and `agentx-databricks`
260
267
  (`pip install "agentx-python[databricks]"`, MLflow/Databricks trace sync).
@@ -190,6 +190,13 @@ extra:
190
190
  | LlamaIndex | `pip install "agentx-python[llamaindex]"` | `AgentXLlamaIndexHandler` |
191
191
  | AutoGen | `pip install "agentx-python[autogen]"` | `AgentXAutoGenObserver` |
192
192
 
193
+ > **Warning: pick ONE instrumentation layer per LLM call.** Do not combine
194
+ > `AgentXCallbackHandler` (or any framework integration) with a patched provider client
195
+ > (`patch_openai_client`, `patch_anthropic_client`, `patch_genai_client`) on the same code
196
+ > path. A patched call that runs outside an active span emits its own root trace, so every
197
+ > LLM call the framework already traces gets a duplicate trace - and its cost is counted
198
+ > twice.
199
+
193
200
  Two more platforms are covered by **pull importers** rather than in-process hooks, each with its
194
201
  own CLI: `agentx-moveworks` (Moveworks Data API sync, no extra needed) and `agentx-databricks`
195
202
  (`pip install "agentx-python[databricks]"`, MLflow/Databricks trace sync).
@@ -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
@@ -224,7 +221,16 @@ class EvaluationsClient:
224
221
  the Sovereignty & Portability Index. Pass ``provider`` (e.g. "Google")
225
222
  to filter."""
226
223
  params = {"provider": provider} if provider else None
227
- data = self._request("GET", "/models", params=params)
224
+ try:
225
+ data = self._request("GET", "/models", params=params)
226
+ except AgentXEvaluationsError as exc:
227
+ if exc.status_code == 404:
228
+ raise AgentXEvaluationsError(
229
+ "list_models is hosted-only; on self-host pass any model id your judge "
230
+ "key can reach, or use client.monitor.* portability models",
231
+ status_code=404,
232
+ ) from exc
233
+ raise
228
234
  items = data if isinstance(data, list) else data.get("models", [])
229
235
  return [ModelInfo(**m) for m in items]
230
236
 
@@ -500,6 +506,17 @@ class EvaluationsClient:
500
506
  return self._report_from_dashboard(run_id)
501
507
 
502
508
  def get_missing_results(self, run_id: str) -> List[Dict[str, Any]]:
509
+ """Deprecated: on self-host the route's response body has no top-level list, so this
510
+ always returns ``[]``. Use :meth:`get_submitted_keys` - the same route's
511
+ ``submittedKeys`` - to find out what a run still needs."""
512
+ import warnings
513
+
514
+ warnings.warn(
515
+ "get_missing_results() always returns [] on self-host - use get_submitted_keys() "
516
+ "to resume a run instead.",
517
+ DeprecationWarning,
518
+ stacklevel=2,
519
+ )
503
520
  data = self._request("GET", f"/runs/{run_id}/missing-results")
504
521
  return data if isinstance(data, list) else data.get("missing", [])
505
522
 
@@ -538,12 +555,20 @@ class EvaluationsClient:
538
555
  ) -> bool:
539
556
  """Return True if ``exc`` is the 404 that means "this engine is self-host".
540
557
 
541
- Only a 404 qualifies. Anything else - auth, validation, a 500, a dead connection -
542
- is a real failure on a route that does exist, and must propagate rather than be
543
- retried against a different endpoint that would mask it.
558
+ Only a route-level 404 qualifies. Anything else - auth, validation, a 500, a dead
559
+ connection - is a real failure on a route that does exist, and must propagate rather
560
+ than be retried against a different endpoint that would mask it.
561
+
562
+ A resource 404 does not qualify either: the engine's SDK router answers these routes
563
+ with bodies naming the missing resource ("Run not found" / "No analysis found for
564
+ this run"), so latching on one would permanently reroute every later analysis call
565
+ to the dashboard router because a caller once passed a wrong run id.
544
566
  """
545
567
  if exc.status_code != 404:
546
568
  return False
569
+ body = str(exc)
570
+ if "Run not found" in body or "No analysis found for this run" in body:
571
+ return False
547
572
  if self._analysis_on_dashboard_router is None:
548
573
  logger.info(
549
574
  "%s is not served from %s; using the dashboard router at %s "
@@ -74,8 +74,21 @@ class EvaluationSettingsBuilder:
74
74
  # Sandboxed JS scorers run per result alongside the judge - each entry is
75
75
  # {"name": ..., "enabled": True, "code": "..."} where the code is a JS function body
76
76
  # receiving (input, output, expected, toolCalls) and returning {score, reasoning}.
77
+ # Normalized the same way DatasetBuilder does: id defaulted, name optional (the
78
+ # engine defaults it), enabled default True - raw pass-through sent entries the
79
+ # engine's shape validation rejects.
77
80
  if code_scorers:
78
- self._payload["codeScorers"] = list(code_scorers)
81
+ import uuid as _uuid
82
+
83
+ self._payload["codeScorers"] = [
84
+ {
85
+ "id": scorer.get("id") or _uuid.uuid4().hex[:12],
86
+ "name": scorer.get("name"),
87
+ "code": scorer["code"],
88
+ "enabled": scorer.get("enabled", True),
89
+ }
90
+ for scorer in code_scorers
91
+ ]
79
92
 
80
93
  def publish(self) -> EvaluationSettings:
81
94
  logger.info("Publishing evaluation settings '%s'", self._payload["name"])
@@ -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
@@ -51,6 +51,10 @@ class TestCase(BaseModel):
51
51
  expected_knowledge_base: Optional[List[str]] = Field(default=None, alias="expectedKnowledgeBase")
52
52
  expected_delegations: Optional[List[str]] = Field(default=None, alias="expectedDelegations")
53
53
  judge_guideline: Optional[str] = Field(default=None, alias="judgeGuideline")
54
+ # Engine-side trajectory match (e.g. {"tools": ["search"], "mode": "in_order"}) and the
55
+ # expected retrieval context for RAG grading - carried so import_dataset round-trips them.
56
+ expected_trajectory: Optional[Dict[str, Any]] = Field(default=None, alias="expectedTrajectory")
57
+ expected_retrieval_context: Optional[Any] = Field(default=None, alias="expectedRetrievalContext")
54
58
  smoke_test: Optional[SmokeTestSettings] = Field(default=None, alias="smokeTest")
55
59
  # Named subsets this case belongs to (e.g. ["smoke"], ["full", "regression"]).
56
60
  # ``run(dataset_id, split="smoke")`` runs only cases tagged with that split.
@@ -78,6 +82,17 @@ class Dataset(BaseModel):
78
82
  # Custom code scorers attached to this dataset - [{ id, name, code, enabled }]. Retrievable,
79
83
  # so a fetched dataset round-trips them (import_dataset copies them to the new dataset).
80
84
  code_scorers: Optional[List[Dict[str, Any]]] = Field(default=None, alias="codeScorers")
85
+ # Grading config carried on the dataset itself - similarity metric toggles (each a
86
+ # {"enabled": bool, ...} object on the wire), LLM-as-judge overrides, and the raw
87
+ # sovereigntyIndex object. Modeled so a fetched Dataset round-trips them: extra="ignore"
88
+ # used to silently drop all of these on read, and import_dataset lost them on the copy.
89
+ vector_similarity: Optional[Any] = Field(default=None, alias="vectorSimilarity")
90
+ jaccard_similarity: Optional[Any] = Field(default=None, alias="jaccardSimilarity")
91
+ bleu_score: Optional[Any] = Field(default=None, alias="bleuScore")
92
+ rouge_score: Optional[Any] = Field(default=None, alias="rougeScore")
93
+ judge_prompt: Optional[str] = Field(default=None, alias="judgePrompt")
94
+ judge_model: Optional[str] = Field(default=None, alias="judgeModel")
95
+ sovereignty_index: Optional[Dict[str, Any]] = Field(default=None, alias="sovereigntyIndex")
81
96
  status: str = "published"
82
97
  version_id: Optional[str] = Field(default=None, alias="versionId")
83
98
  # Sovereignty & Portability - models selected to compare on this dataset.
@@ -430,7 +445,12 @@ class RunResultRow(BaseModel):
430
445
  question_index: Optional[int] = Field(default=None, alias="questionIndex")
431
446
  run_number: Optional[int] = Field(default=None, alias="runNumber")
432
447
  question_text: Optional[str] = Field(default=None, alias="questionText")
433
- response: Optional[str] = None
448
+ # The engine sends the agent's answer as an `output` OBJECT ({"text": ...}), not a
449
+ # `response` string - accept both spellings and lift the dict's text (see the
450
+ # validator below), so row.response actually populates on self-host.
451
+ response: Optional[str] = Field(
452
+ default=None, validation_alias=AliasChoices("response", "output")
453
+ )
434
454
  trace_id: Optional[str] = Field(default=None, alias="traceId")
435
455
  latency_ms: Optional[float] = Field(default=None, alias="latencyMs")
436
456
  input_tokens: Optional[int] = Field(default=None, alias="inputTokens")
@@ -452,6 +472,15 @@ class RunResultRow(BaseModel):
452
472
  populate_by_name = True
453
473
  extra = "ignore"
454
474
 
475
+ @field_validator("response", mode="before")
476
+ @classmethod
477
+ def _lift_output_text(cls, value: Any) -> Any:
478
+ # The `output` alias delivers the wire's whole output object - keep the declared
479
+ # Optional[str] by lifting its text field.
480
+ if isinstance(value, dict):
481
+ return value.get("text")
482
+ return value
483
+
455
484
  @classmethod
456
485
  def from_wire(cls, wire: Dict[str, Any]) -> "RunResultRow":
457
486
  row = cls.model_validate(wire)
@@ -170,7 +170,7 @@ def print_report(report: Report) -> None:
170
170
 
171
171
  # --- Low-scoring cases ---
172
172
  if report.low_scoring_cases:
173
- _section("Low-scoring Cases (rating < 5)")
173
+ _section("Low-scoring Cases (rating <= 5)")
174
174
  for case in report.low_scoring_cases[:5]:
175
175
  q = (case.get("query") or case.get("questionText", ""))[:80]
176
176
  rating = case.get("rating", "?")
@@ -87,6 +87,16 @@ def normalize_result(
87
87
  else:
88
88
  output = {"text": str(raw)} if raw is not None else {"text": ""}
89
89
 
90
+ if error is None and (
91
+ output is None
92
+ or (set(output) <= {"text"} and not str(output.get("text") or "").strip())
93
+ ):
94
+ # An empty output with no error would fail the engine's row validation and silently
95
+ # vanish from the run - store it as an explicit failed row instead.
96
+ error = ResultError(type="EmptyOutput", message="Agent returned no output")
97
+ if output is None:
98
+ output = {"text": ""}
99
+
90
100
  has_timings = (
91
101
  latency_ms is not None or input_tokens is not None or output_tokens is not None
92
102
  )
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import logging
4
4
  import os
5
+ import sys
5
6
  import time
6
7
 
7
8
  import requests
@@ -11,7 +12,11 @@ from typing import Any, Callable, Dict, Iterator, List, Optional, Set, Union
11
12
  from agentx.evaluations.adapters.raw import RawCallableAdapter
12
13
  from agentx.evaluations.adapters.precomputed import PrecomputedAdapter
13
14
  from agentx.evaluations.adapters.http_endpoint import HttpEndpointAdapter
14
- from agentx.evaluations.client import EvaluationsClient, EvaluationSubmissionError
15
+ from agentx.evaluations.client import (
16
+ AgentXEvaluationsError,
17
+ EvaluationsClient,
18
+ EvaluationSubmissionError,
19
+ )
15
20
  from agentx.evaluations.models import (
16
21
  AnalysisStatus,
17
22
  Dataset,
@@ -227,6 +232,7 @@ class EvaluationRunContext:
227
232
  if concurrency > 1:
228
233
  import concurrent.futures
229
234
  import contextvars
235
+ from collections import deque
230
236
 
231
237
  def in_scope(case: EvaluationCase) -> EvaluationResult:
232
238
  # ContextVars (the eval-run scope) do not cross thread boundaries on their own -
@@ -240,48 +246,85 @@ class EvaluationRunContext:
240
246
  if _idem_key(self._run.run_id, case.case_id, case.run_number) not in already_done
241
247
  ]
242
248
  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
249
 
246
- def ordered() -> "Iterator[EvaluationResult]":
250
+ def bounded() -> "Iterator[EvaluationResult]":
251
+ # Bounded submit loop instead of executor.map(): map() dispatches EVERY case
252
+ # up front, so a fail-fast flush failure (EvaluationSubmissionError below)
253
+ # still paid for the whole rest of the run in agent calls. Keep at most
254
+ # `concurrency` cases in flight, topping up as results are consumed; yields
255
+ # stay in submission order so batching below is deterministic. On teardown
256
+ # (an exception in the consuming loop closes this generator) whatever is
257
+ # queued but unstarted is cancelled.
258
+ import itertools
259
+
260
+ case_iter = iter(pending)
261
+ in_flight: "deque[concurrent.futures.Future]" = deque()
247
262
  try:
248
- yield from mapped
263
+ for case in itertools.islice(case_iter, concurrency):
264
+ in_flight.append(executor.submit(in_scope, case))
265
+ while in_flight:
266
+ result = in_flight.popleft().result()
267
+ next_case = next(case_iter, None)
268
+ if next_case is not None:
269
+ in_flight.append(executor.submit(in_scope, next_case))
270
+ yield result
249
271
  finally:
250
- executor.shutdown(wait=True)
272
+ executor.shutdown(wait=False, cancel_futures=True)
251
273
 
252
- results_iter = ordered()
274
+ results_iter = bounded()
253
275
  else:
254
276
  results_iter = None # sequential path below produces inline
255
277
 
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)
278
-
279
- if len(batch) >= max_batch:
280
- self._flush_batch(batch)
281
- batch = []
278
+ try:
279
+ for idx, case in enumerate(cases, start=1):
280
+ idem_key = _idem_key(self._run.run_id, case.case_id, case.run_number)
281
+
282
+ if idem_key in already_done:
283
+ logger.debug("Skipping already-submitted case: %s", idem_key)
284
+ _print_progress(idx, total, case, skipped=True)
285
+ continue
286
+
287
+ result = next(results_iter) if results_iter is not None else produce(case)
288
+ result.idempotency_key = idem_key
289
+ # Tag the result with the case's model so the server can group it into
290
+ # the Sovereignty & Portability matrix (the callable may also set it).
291
+ if case.model:
292
+ meta = dict(result.metadata or {})
293
+ meta.setdefault("model", case.model)
294
+ result.metadata = meta
295
+ result = EvaluationResult(
296
+ **{**result.model_dump(), "idempotencyKey": idem_key}
297
+ )
298
+ self._results.append(result)
299
+ batch.append(result)
300
+ _print_progress(idx, total, case, result=result)
282
301
 
283
- if batch:
284
- self._flush_batch(batch)
302
+ if len(batch) >= max_batch:
303
+ self._flush_batch(batch)
304
+ batch = []
305
+ finally:
306
+ # Deterministic teardown: a flush failure mid-run must stop the in-flight agent
307
+ # dispatch NOW (bounded()'s finally cancels queued cases), not whenever the
308
+ # generator happens to be garbage-collected.
309
+ if results_iter is not None:
310
+ results_iter.close()
311
+ # Flush the trailing partial batch HERE, not after the try: a mid-run exception
312
+ # (agent crash, Ctrl-C) used to discard up to max_batch - 1 already-paid-for
313
+ # results still waiting in it.
314
+ if batch:
315
+ propagating = sys.exc_info()[1]
316
+ try:
317
+ self._flush_batch(batch)
318
+ except Exception as flush_exc:
319
+ if propagating is None:
320
+ raise
321
+ # An exception is already propagating out of the loop - a flush failure
322
+ # here must not mask it.
323
+ logger.error(
324
+ "Trailing batch flush failed while handling %r: %s",
325
+ propagating,
326
+ flush_exc,
327
+ )
285
328
 
286
329
  return self
287
330
 
@@ -298,6 +341,15 @@ class EvaluationRunContext:
298
341
  _say(
299
342
  f" {green('✓')} Scored {resp.accepted} result{'s' if resp.accepted != 1 else ''}"
300
343
  )
344
+ if resp.failed_validation > 0:
345
+ # The engine accepts the batch but silently drops rows that fail its
346
+ # validation (typically empty output and no error) - say so, or those
347
+ # cases just vanish from the report.
348
+ _say(
349
+ f" {yellow('!')} {resp.failed_validation} result"
350
+ f"{'s' if resp.failed_validation != 1 else ''} failed validation "
351
+ "(empty output and no error) and did not get stored"
352
+ )
301
353
  logger.info(
302
354
  "Batch %s: accepted=%d duplicates=%d failed=%d",
303
355
  batch_id[:8],
@@ -333,9 +385,21 @@ class EvaluationRunContext:
333
385
  so a re-execute() after a crash skips (and never re-pays for) finished cases."""
334
386
  try:
335
387
  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()
388
+ except AgentXEvaluationsError as exc:
389
+ if exc.status_code == 404:
390
+ # Older engines without the route: no resume, identical to the historical
391
+ # behavior. ONLY the 404 qualifies - a transient 502/timeout here used to be
392
+ # swallowed too, and an empty resume set silently re-runs (and re-bills)
393
+ # every already-finished case.
394
+ return set()
395
+ _say(f" {red('✗')} Could not fetch already-submitted keys: {dim(str(exc))}")
396
+ logger.error(
397
+ "Resume-key fetch for run %s failed (%s) - refusing to re-run the whole run "
398
+ "blind; retry execute() once the engine is reachable",
399
+ self._run.run_id,
400
+ exc,
401
+ )
402
+ raise
339
403
 
340
404
  # ------------------------------------------------------------------
341
405
  # Step 2: finalize
@@ -375,8 +439,11 @@ class EvaluationRunContext:
375
439
  ``no_regression=True`` fails it when the average dropped more than ``tolerance``
376
440
  (default 0.5, judge scores are noisy) below the dataset's previous completed run.
377
441
  At least one check is required. On a multi-judge run, ``scorer`` (an additional
378
- scorer's id or name, e.g. ``scorer="Safety"``) gates that scorer's own average
379
- instead of the primary's - "fail if Safety is low even when the average looks fine". Prints a CI-log-friendly verdict and returns a
442
+ judge scorer's id or name, e.g. ``scorer="Safety"``) gates that scorer's own average
443
+ instead of the primary's - "fail if Safety is low even when the average looks fine".
444
+ Only judge scorers resolve here: deterministic scorer-group members (pattern/code
445
+ kinds) have no per-run judge average, so naming one is rejected by the engine.
446
+ Prints a CI-log-friendly verdict and returns a
380
447
  :class:`GateResult` - the caller decides the exit code::
381
448
 
382
449
  report = client.evaluations.run(...).execute(my_agent).finalize()
@@ -429,6 +496,16 @@ class EvaluationRunContext:
429
496
  """Number of submitted results that have received a rating so far."""
430
497
  return self._live_stats.rated_count if self._live_stats else 0
431
498
 
499
+ @property
500
+ def skipped_count(self) -> int:
501
+ """Number of submitted results the judge could not score."""
502
+ return self._live_stats.skipped_count if self._live_stats else 0
503
+
504
+ @property
505
+ def failed_count(self) -> int:
506
+ """Number of submitted results that carried an error."""
507
+ return self._live_stats.failed_count if self._live_stats else 0
508
+
432
509
  @property
433
510
  def average_rating(self) -> Optional[float]:
434
511
  """Live average rating across all results scored so far. Populated as
@@ -465,6 +542,8 @@ class EvaluationRunContext:
465
542
 
466
543
  Args:
467
544
  mode: "auto" (default), "sync", or "batch" - how item scoring executes server-side.
545
+ Hosted-only: self-host runs the analysis synchronously regardless; see the
546
+ response's mode field for what actually ran.
468
547
  quality_mode: "quality_first" or "balanced" - how many items get a second/third judge.
469
548
  judges: 1-3 model ids from ``client.evaluations.list_models()``, e.g.
470
549
  ``["gpt-5.6-luna", "claude-opus-4-8"]``. Omit to let the engine score with its
@@ -597,6 +676,36 @@ class EvaluationsRunner:
597
676
  script execution)."""
598
677
  return self._client.get_analysis_status(run_id)
599
678
 
679
+ # Run-lifecycle calls by id - the standalone forms of what run()/execute()/finalize()/
680
+ # analyze() drive for you, for scripts operating on a run created elsewhere.
681
+
682
+ def init_run(self, dataset_id: str, subject, **kwargs):
683
+ """Create a run row without executing anything - the standalone form of :meth:`run`.
684
+ Accepts the same kwargs as ``EvaluationsClient.init_run``."""
685
+ return self._client.init_run(dataset_id, subject, **kwargs)
686
+
687
+ def append_results(self, run_id: str, batch_id: str, results: list):
688
+ """Submit one batch of results to a run by id (scored synchronously server-side)."""
689
+ return self._client.append_results(run_id, batch_id, results)
690
+
691
+ def finalize_run(self, run_id: str) -> dict:
692
+ """Mark a run completed by id - the standalone form of
693
+ ``EvaluationRunContext.finalize()``."""
694
+ return self._client.finalize_run(run_id)
695
+
696
+ def analyze_run(self, run_id: str, **kwargs) -> dict:
697
+ """Start the LLM analysis of a finalized run by id; poll
698
+ :meth:`get_analysis_status`, then :meth:`get_report`."""
699
+ return self._client.analyze_run(run_id, **kwargs)
700
+
701
+ def get_report(self, run_id: str):
702
+ """The analyzed report for a run by id, once analysis has finished."""
703
+ return self._client.get_report(run_id)
704
+
705
+ def get_submitted_keys(self, run_id: str) -> list:
706
+ """Idempotency keys a run has already accepted - what execute() uses to resume."""
707
+ return self._client.get_submitted_keys(run_id)
708
+
600
709
  def gate_run(
601
710
  self,
602
711
  run_id: str,