agentx-python 0.8.22__tar.gz → 0.8.24__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.22 → agentx_python-0.8.24}/PKG-INFO +8 -1
  2. {agentx_python-0.8.22 → agentx_python-0.8.24}/README.md +7 -0
  3. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/agentx.py +28 -5
  4. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/evaluations/client.py +40 -13
  5. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/evaluations/datasets.py +19 -5
  6. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/evaluations/evaluation_settings.py +26 -3
  7. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/evaluations/models.py +14 -15
  8. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/evaluations/reporting.py +2 -2
  9. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/evaluations/results.py +10 -0
  10. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/evaluations/runner.py +99 -14
  11. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/integrations/_traced_call.py +5 -1
  12. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/integrations/autogen.py +4 -1
  13. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/integrations/crewai.py +23 -10
  14. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/integrations/google_adk.py +110 -15
  15. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/integrations/langchain.py +61 -28
  16. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/integrations/litellm.py +17 -2
  17. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/integrations/llamaindex.py +103 -89
  18. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/integrations/openai_agents.py +3 -0
  19. agentx_python-0.8.24/agentx/monitor/__init__.py +39 -0
  20. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/monitor/agents.py +13 -2
  21. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/monitor/client.py +25 -9
  22. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/monitor/improvement_groups.py +39 -4
  23. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/monitor/judge_scorers.py +68 -8
  24. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/monitor/models.py +11 -5
  25. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/monitor/patterns.py +34 -1
  26. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/monitor/profile.py +2 -0
  27. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/monitor/review_queue.py +10 -2
  28. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/monitor/rules.py +6 -2
  29. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/monitor/scorer_groups.py +33 -4
  30. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/monitor/scorers.py +37 -4
  31. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/tracing/ingest_client.py +39 -9
  32. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/tracing/tracer.py +116 -31
  33. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/version.py +2 -2
  34. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx_python.egg-info/PKG-INFO +8 -1
  35. {agentx_python-0.8.22 → agentx_python-0.8.24}/tests/test_docs_match_sdk.py +15 -0
  36. {agentx_python-0.8.22 → agentx_python-0.8.24}/tests/test_judge_scorers.py +48 -2
  37. {agentx_python-0.8.22 → agentx_python-0.8.24}/tests/test_selfhost_analysis_fallback.py +21 -0
  38. {agentx_python-0.8.22 → agentx_python-0.8.24}/tests/test_span_tree.py +73 -0
  39. {agentx_python-0.8.22 → agentx_python-0.8.24}/tests/test_wire_models.py +43 -0
  40. agentx_python-0.8.22/agentx/monitor/__init__.py +0 -17
  41. {agentx_python-0.8.22 → agentx_python-0.8.24}/LICENSE +0 -0
  42. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/__init__.py +0 -0
  43. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/cli.py +0 -0
  44. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/evaluations/__init__.py +0 -0
  45. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/evaluations/_term.py +0 -0
  46. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/evaluations/adapters/__init__.py +0 -0
  47. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/evaluations/adapters/http_endpoint.py +0 -0
  48. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/evaluations/adapters/precomputed.py +0 -0
  49. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/evaluations/adapters/raw.py +0 -0
  50. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/evaluations/prompts.py +0 -0
  51. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/evaluations/tool_schemas.py +0 -0
  52. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/evaluations/tracing.py +0 -0
  53. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/exceptions.py +0 -0
  54. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/export.py +0 -0
  55. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/feedback.py +0 -0
  56. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/integrations/__init__.py +0 -0
  57. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/integrations/anthropic.py +0 -0
  58. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/integrations/databricks.py +0 -0
  59. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/integrations/google_genai.py +0 -0
  60. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/integrations/moveworks.py +0 -0
  61. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/integrations/openai.py +0 -0
  62. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/monitor/online_evaluators.py +0 -0
  63. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/monitor/sessions.py +0 -0
  64. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/monitor/signals.py +0 -0
  65. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/outcomes.py +0 -0
  66. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/projects.py +0 -0
  67. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/py.typed +0 -0
  68. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/resources/__init__.py +0 -0
  69. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/resources/agent.py +0 -0
  70. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/resources/conversation.py +0 -0
  71. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/resources/workforce.py +0 -0
  72. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/testing.py +0 -0
  73. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/traces.py +0 -0
  74. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/tracing/__init__.py +0 -0
  75. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/tracing/ci_types.py +0 -0
  76. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/tracing/eval_scope.py +0 -0
  77. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/tracing/framework_detect.py +0 -0
  78. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx/util.py +0 -0
  79. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx_python.egg-info/SOURCES.txt +0 -0
  80. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx_python.egg-info/dependency_links.txt +0 -0
  81. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx_python.egg-info/entry_points.txt +0 -0
  82. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx_python.egg-info/not-zip-safe +0 -0
  83. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx_python.egg-info/requires.txt +0 -0
  84. {agentx_python-0.8.22 → agentx_python-0.8.24}/agentx_python.egg-info/top_level.txt +0 -0
  85. {agentx_python-0.8.22 → agentx_python-0.8.24}/setup.cfg +0 -0
  86. {agentx_python-0.8.22 → agentx_python-0.8.24}/setup.py +0 -0
  87. {agentx_python-0.8.22 → agentx_python-0.8.24}/tests/test_cli_launcher.py +0 -0
  88. {agentx_python-0.8.22 → agentx_python-0.8.24}/tests/test_deep_dive_fixes.py +0 -0
  89. {agentx_python-0.8.22 → agentx_python-0.8.24}/tests/test_error_taxonomy.py +0 -0
  90. {agentx_python-0.8.22 → agentx_python-0.8.24}/tests/test_eval_scope.py +0 -0
  91. {agentx_python-0.8.22 → agentx_python-0.8.24}/tests/test_framework_detect.py +0 -0
  92. {agentx_python-0.8.22 → agentx_python-0.8.24}/tests/test_integration.py +0 -0
  93. {agentx_python-0.8.22 → agentx_python-0.8.24}/tests/test_integrations.py +0 -0
  94. {agentx_python-0.8.22 → agentx_python-0.8.24}/tests/test_multi_judge.py +0 -0
  95. {agentx_python-0.8.22 → agentx_python-0.8.24}/tests/test_pairwise.py +0 -0
  96. {agentx_python-0.8.22 → agentx_python-0.8.24}/tests/test_review_queue.py +0 -0
  97. {agentx_python-0.8.22 → agentx_python-0.8.24}/tests/test_runner_features.py +0 -0
  98. {agentx_python-0.8.22 → agentx_python-0.8.24}/tests/test_selfhost_compat.py +0 -0
  99. {agentx_python-0.8.22 → agentx_python-0.8.24}/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.22
3
+ Version: 0.8.24
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).
@@ -4,6 +4,7 @@ import os
4
4
  import logging
5
5
 
6
6
  from agentx.util import get_headers, api_base, normalize_base
7
+ from agentx.exceptions import AgentXError
7
8
  from agentx.resources.agent import Agent
8
9
  from agentx.resources.workforce import Workforce
9
10
 
@@ -136,6 +137,11 @@ class AgentX:
136
137
  return cls(base_url=base_url) if base_url else cls()
137
138
 
138
139
  def get_agent(self, id: str) -> Agent:
140
+ """Fetch one hosted-platform agent by id.
141
+
142
+ Hosted platform only - the self-host engine does not serve /access/agents;
143
+ use ``client.monitor.agents.list()`` for self-host agent rows instead.
144
+ """
139
145
  url = f"{self.base_url or api_base()}/access/agents/{id}"
140
146
  # Make a GET request to the AgentX API
141
147
  response = requests.get(url, headers=get_headers(self.api_key))
@@ -143,9 +149,17 @@ class AgentX:
143
149
  if response.status_code == 200:
144
150
  return Agent(**response.json())
145
151
  else:
146
- raise Exception(f"Failed to retrieve agent: {response.reason}")
152
+ raise AgentXError(
153
+ f"Failed to retrieve agent: {response.reason}. This endpoint is "
154
+ "hosted-platform only - on self-host use client.monitor.agents.list()."
155
+ )
147
156
 
148
157
  def list_agents(self) -> List[Agent]:
158
+ """List the hosted platform's agents.
159
+
160
+ Hosted platform only - the self-host engine does not serve /access/agents;
161
+ use ``client.monitor.agents.list()`` for self-host agent rows instead.
162
+ """
149
163
  url = f"{self.base_url or api_base()}/access/agents"
150
164
  # Make a GET request to the AgentX API
151
165
  response = requests.get(url, headers=get_headers(self.api_key))
@@ -153,7 +167,10 @@ class AgentX:
153
167
  if response.status_code == 200:
154
168
  return [Agent(**agent) for agent in response.json()]
155
169
  else:
156
- raise Exception(f"Failed to list agents: {response.reason}")
170
+ raise AgentXError(
171
+ f"Failed to list agents: {response.reason}. This endpoint is "
172
+ "hosted-platform only - on self-host use client.monitor.agents.list()."
173
+ )
157
174
 
158
175
  @staticmethod
159
176
  def list_workforces() -> List["Workforce"]:
@@ -214,12 +231,18 @@ class AgentX:
214
231
  return {"ok": True, "base_url": base}
215
232
 
216
233
  def get_profile(self):
217
- """Get the current user's profile information."""
234
+ """Get the current user's profile information.
235
+
236
+ Hosted platform only - the self-host engine does not serve /access/getProfile;
237
+ self-host agent/monitoring data lives under ``client.monitor`` (e.g.
238
+ ``client.monitor.agents.list()``).
239
+ """
218
240
  url = f"{self.base_url or api_base()}/access/getProfile"
219
241
  response = requests.get(url, headers=get_headers(self.api_key))
220
242
  if response.status_code == 200:
221
243
  return response.json()
222
244
  else:
223
- raise Exception(
224
- f"Failed to get profile: {response.status_code} - {response.reason}"
245
+ raise AgentXError(
246
+ f"Failed to get profile: {response.status_code} - {response.reason}. "
247
+ "This endpoint is hosted-platform only - on self-host use client.monitor."
225
248
  )
@@ -35,7 +35,6 @@ _DEFAULT_BASE_URL = f"{_UTIL_API_BASE}/custom-agent-evaluations"
35
35
  SDK_NAME = "agentx-python"
36
36
 
37
37
  _RETRYABLE_STATUS = {429, 500, 502, 503, 504}
38
- _MAX_RETRIES = 3
39
38
  _RETRY_BACKOFF = [1.0, 2.0, 4.0]
40
39
 
41
40
  # The self-host analyze route judges every result before it responds, so the client has to
@@ -182,8 +181,8 @@ class EvaluationsClient:
182
181
  if resp.status_code == 422:
183
182
  raise AgentXValidationError(resp.text)
184
183
  # 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).
184
+ # schedule connection errors do - an earlier fixed retry-count gate left the
185
+ # schedule's last entry unreachable for HTTP retries (ingest_client precedent).
187
186
  if (
188
187
  resp.status_code in _RETRYABLE_STATUS
189
188
  and retry
@@ -221,7 +220,16 @@ class EvaluationsClient:
221
220
  the Sovereignty & Portability Index. Pass ``provider`` (e.g. "Google")
222
221
  to filter."""
223
222
  params = {"provider": provider} if provider else None
224
- data = self._request("GET", "/models", params=params)
223
+ try:
224
+ data = self._request("GET", "/models", params=params)
225
+ except AgentXEvaluationsError as exc:
226
+ if exc.status_code == 404:
227
+ raise AgentXEvaluationsError(
228
+ "list_models is hosted-only; on self-host pass any model id your judge "
229
+ "key can reach, or use client.monitor.* portability models",
230
+ status_code=404,
231
+ ) from exc
232
+ raise
225
233
  items = data if isinstance(data, list) else data.get("models", [])
226
234
  return [ModelInfo(**m) for m in items]
227
235
 
@@ -239,7 +247,9 @@ class EvaluationsClient:
239
247
  """Deletes the dataset, its grading config, and both version histories. Past runs are
240
248
  kept (their dataset reference degrades to a bare id). The engine refuses (409) when the
241
249
  dataset's config is attached to a live scorer."""
242
- self._request("DELETE", f"/datasets/{dataset_id}")
250
+ # retry=False: a lost response + transport retry would turn a successful
251
+ # delete into a spurious 404.
252
+ self._request("DELETE", f"/datasets/{dataset_id}", retry=False)
243
253
 
244
254
  def list_datasets(self) -> List[Dataset]:
245
255
  data = self._request("GET", "/datasets", params=self._workspace_params())
@@ -431,6 +441,12 @@ class EvaluationsClient:
431
441
  quality_mode: Optional[str] = None,
432
442
  judges: Optional[List[str]] = None,
433
443
  ) -> Dict[str, Any]:
444
+ """Start the qualitative AI-analysis job for a run.
445
+
446
+ ``mode`` ("auto"/"sync"/"batch") is hosted-only: self-host engines run the analysis
447
+ synchronously and ignore it - check the response's mode field for what actually ran
448
+ (mirrors EvaluationRun.analyze's docstring).
449
+ """
434
450
  # Starts the durable analysis job and returns immediately (e.g. {"jobId": ..., "status":
435
451
  # "pending"}); poll get_analysis_status() until it reaches a terminal status, then call
436
452
  # get_report(). mode/quality_mode/judges mirror the dashboard's AnalyzeEvaluationRequest.
@@ -497,9 +513,10 @@ class EvaluationsClient:
497
513
  return self._report_from_dashboard(run_id)
498
514
 
499
515
  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."""
516
+ """Deprecated: on self-host the route returns ``missing: []`` deliberately empty -
517
+ the engine cannot know the client's case list - so this always returns ``[]``.
518
+ Use :meth:`get_submitted_keys` - the same route's ``submittedKeys`` - to find out
519
+ what a run still needs."""
503
520
  import warnings
504
521
 
505
522
  warnings.warn(
@@ -508,8 +525,10 @@ class EvaluationsClient:
508
525
  DeprecationWarning,
509
526
  stacklevel=2,
510
527
  )
511
- data = self._request("GET", f"/runs/{run_id}/missing-results")
512
- return data if isinstance(data, list) else data.get("missing", [])
528
+ # No request at all: the route returns `missing: []` deliberately empty (the engine
529
+ # cannot know the client's case list), so the round-trip only ever bought an empty
530
+ # result.
531
+ return []
513
532
 
514
533
  def get_submitted_keys(self, run_id: str) -> List[str]:
515
534
  """Idempotency keys this run has already accepted - what execute() uses to resume a
@@ -546,12 +565,20 @@ class EvaluationsClient:
546
565
  ) -> bool:
547
566
  """Return True if ``exc`` is the 404 that means "this engine is self-host".
548
567
 
549
- Only a 404 qualifies. Anything else - auth, validation, a 500, a dead connection -
550
- is a real failure on a route that does exist, and must propagate rather than be
551
- retried against a different endpoint that would mask it.
568
+ Only a route-level 404 qualifies. Anything else - auth, validation, a 500, a dead
569
+ connection - is a real failure on a route that does exist, and must propagate rather
570
+ than be retried against a different endpoint that would mask it.
571
+
572
+ A resource 404 does not qualify either: the engine's SDK router answers these routes
573
+ with bodies naming the missing resource ("Run not found" / "No analysis found for
574
+ this run"), so latching on one would permanently reroute every later analysis call
575
+ to the dashboard router because a caller once passed a wrong run id.
552
576
  """
553
577
  if exc.status_code != 404:
554
578
  return False
579
+ body = str(exc)
580
+ if "Run not found" in body or "No analysis found for this run" in body:
581
+ return False
555
582
  if self._analysis_on_dashboard_router is None:
556
583
  logger.info(
557
584
  "%s is not served from %s; using the dashboard router at %s "
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import csv
4
4
  import logging
5
+ import warnings
5
6
  from pathlib import Path
6
7
  from typing import Any, Dict, List, Optional, TYPE_CHECKING, Union
7
8
 
@@ -21,7 +22,8 @@ class DatasetBuilder:
21
22
  ``judge_prompt``/``judge_model`` are LLM-as-judge overrides for the dataset's grading
22
23
  config. NOTE (self-host): the engine's dataset-create route currently ignores both -
23
24
  set them on a judge scorer / the evaluation settings instead. ``sovereignty_models``
24
- is accepted on the wire but not acted on by the self-host engine.
25
+ is dropped by the self-host engine on this route - use
26
+ ``client.monitor.judge_scorers.builder(sovereignty_models=...)`` which persists it.
25
27
  """
26
28
 
27
29
  def __init__(
@@ -93,8 +95,9 @@ class DatasetBuilder:
93
95
  if rouge_score:
94
96
  self._payload["rougeScore"] = {"enabled": True}
95
97
  # Sovereignty & Portability - the models to compare on this dataset (use
96
- # client.evaluations.list_models() to discover valid ids). Self-host: accepted on
97
- # the wire but not acted on by the engine (see class docstring).
98
+ # client.evaluations.list_models() to discover valid ids). Self-host: dropped by
99
+ # the engine on this route - use client.monitor.judge_scorers.builder(
100
+ # sovereignty_models=...) which persists it (see class docstring).
98
101
  if sovereignty_models:
99
102
  self._payload["sovereigntyIndex"] = {
100
103
  "enabled": True,
@@ -161,9 +164,11 @@ class DatasetBuilder:
161
164
  main["smokeTest"] = {"enabled": True, "count": smoke_test_count}
162
165
  if smoke_test_guidance:
163
166
  main["smokeTest"]["guidance"] = smoke_test_guidance
164
- if expected_tools:
167
+ # `is not None`, not truthiness: an explicit empty list is a real assertion (an empty
168
+ # expectedTrajectory means "this case calls no tools") and must reach the wire.
169
+ if expected_tools is not None:
165
170
  main["expectedTrajectory"] = {"tools": expected_tools, "mode": trajectory_match_mode}
166
- if expected_retrieval_context:
171
+ if expected_retrieval_context is not None:
167
172
  main["expectedRetrievalContext"] = expected_retrieval_context
168
173
  if splits:
169
174
  main["splits"] = splits
@@ -178,6 +183,15 @@ class DatasetBuilder:
178
183
  def publish(self) -> Dataset:
179
184
  if not self._payload["questions"]:
180
185
  raise ValueError("Dataset must have at least one case before publishing")
186
+ # Warn at publish time, where the request is known: the engine's dataset-create
187
+ # route drops sovereigntyIndex, so comparison models set here never persist.
188
+ sov = self._payload.get("sovereigntyIndex")
189
+ if isinstance(sov, dict) and sov.get("models"):
190
+ warnings.warn(
191
+ "Self-host ignores sovereigntyIndex on datasets/grading configs - use "
192
+ "judge_scorers.builder(sovereignty_models=...) for model comparison runs.",
193
+ stacklevel=2,
194
+ )
181
195
  logger.info(
182
196
  "Publishing dataset '%s' with %d case(s)",
183
197
  self._payload["name"],
@@ -64,8 +64,9 @@ 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). Self-host: accepted
68
- # on the wire but not acted on by the engine (same caveat as DatasetBuilder's).
67
+ # (use client.evaluations.list_models() to discover valid ids). Self-host: dropped
68
+ # by the engine on this route - use client.monitor.judge_scorers.builder(
69
+ # sovereignty_models=...) which persists it (same caveat as DatasetBuilder's).
69
70
  if sovereignty_models:
70
71
  self._payload["sovereigntyIndex"] = {
71
72
  "enabled": True,
@@ -74,10 +75,32 @@ class EvaluationSettingsBuilder:
74
75
  # Sandboxed JS scorers run per result alongside the judge - each entry is
75
76
  # {"name": ..., "enabled": True, "code": "..."} where the code is a JS function body
76
77
  # receiving (input, output, expected, toolCalls) and returning {score, reasoning}.
78
+ # Normalized the same way DatasetBuilder does: id defaulted, name optional (the
79
+ # engine defaults it), enabled default True - raw pass-through sent entries the
80
+ # engine's shape validation rejects.
77
81
  if code_scorers:
78
- self._payload["codeScorers"] = list(code_scorers)
82
+ import uuid as _uuid
83
+
84
+ self._payload["codeScorers"] = [
85
+ {
86
+ "id": scorer.get("id") or _uuid.uuid4().hex[:12],
87
+ "name": scorer.get("name"),
88
+ "code": scorer["code"],
89
+ "enabled": scorer.get("enabled", True),
90
+ }
91
+ for scorer in code_scorers
92
+ ]
79
93
 
80
94
  def publish(self) -> EvaluationSettings:
95
+ # Warn at publish time, where the request is known: the engine's settings-create
96
+ # route drops sovereigntyIndex, so comparison models set here never persist.
97
+ sov = self._payload.get("sovereigntyIndex")
98
+ if isinstance(sov, dict) and sov.get("models"):
99
+ warnings.warn(
100
+ "Self-host ignores sovereigntyIndex on datasets/grading configs - use "
101
+ "judge_scorers.builder(sovereignty_models=...) for model comparison runs.",
102
+ stacklevel=2,
103
+ )
81
104
  logger.info("Publishing evaluation settings '%s'", self._payload["name"])
82
105
  return self._client.create_evaluation_settings(self._payload)
83
106
 
@@ -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.
@@ -175,27 +179,22 @@ class Prompt(BaseModel):
175
179
  # Evaluation subject
176
180
  # ---------------------------------------------------------------------------
177
181
 
178
- FrameworkKind = Literal[
179
- "raw_python",
180
- "openai",
181
- "anthropic",
182
- "google",
183
- "langchain",
184
- "llamaindex",
185
- "crewai",
186
- "autogen",
187
- "n8n",
188
- "flowise",
189
- "other",
190
- ]
191
-
192
182
  RuntimeKind = Literal["local", "ci", "customer_hosted", "low_code"]
193
183
 
194
184
 
195
185
  class EvaluationSubject(BaseModel):
186
+ """Describes the agent under evaluation.
187
+
188
+ ``framework`` is an open string - the engine accepts any label (it also stamps
189
+ values like ``openai-agents``, ``langgraph``, ``google-genai``, ``litellm`` from
190
+ the tracing integrations). Common values: ``raw_python``, ``openai``,
191
+ ``anthropic``, ``google``, ``langchain``, ``llamaindex``, ``crewai``,
192
+ ``autogen``, ``n8n``, ``flowise``, ``other``.
193
+ """
194
+
196
195
  kind: Literal["custom_agent", "agentx_agent", "agentx_team"] = "custom_agent"
197
196
  display_name: Optional[str] = Field(default=None, alias="displayName")
198
- framework: Optional[FrameworkKind] = None
197
+ framework: Optional[str] = None
199
198
  framework_version: Optional[str] = Field(default=None, alias="frameworkVersion")
200
199
  runtime: Optional[RuntimeKind] = "local"
201
200
  agent_instructions: Optional[str] = Field(default=None, alias="agentInstructions")
@@ -170,9 +170,9 @@ 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
- q = (case.get("query") or case.get("questionText", ""))[:80]
175
+ q = (case.get("query") or case.get("questionText") or "")[:80]
176
176
  rating = case.get("rating", "?")
177
177
  justification = case.get("justification", "")
178
178
  print(f" {red(f'[{rating}]')} {q}")
@@ -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
@@ -228,6 +229,7 @@ class EvaluationRunContext:
228
229
  )
229
230
  return normalized(case)
230
231
 
232
+ executor = None
231
233
  if concurrency > 1:
232
234
  import concurrent.futures
233
235
  import contextvars
@@ -307,9 +309,29 @@ class EvaluationRunContext:
307
309
  # generator happens to be garbage-collected.
308
310
  if results_iter is not None:
309
311
  results_iter.close()
310
-
311
- if batch:
312
- self._flush_batch(batch)
312
+ # And release the pool itself here too: close() on a NEVER-STARTED generator
313
+ # (e.g. every case was already submitted, so next() was never called) does not
314
+ # run bounded()'s finally - its executor.shutdown would never fire. shutdown()
315
+ # is idempotent, so the double call on the normal path is harmless.
316
+ if executor is not None:
317
+ executor.shutdown(wait=False, cancel_futures=True)
318
+ # Flush the trailing partial batch HERE, not after the try: a mid-run exception
319
+ # (agent crash, Ctrl-C) used to discard up to max_batch - 1 already-paid-for
320
+ # results still waiting in it.
321
+ if batch:
322
+ propagating = sys.exc_info()[1]
323
+ try:
324
+ self._flush_batch(batch)
325
+ except Exception as flush_exc:
326
+ if propagating is None:
327
+ raise
328
+ # An exception is already propagating out of the loop - a flush failure
329
+ # here must not mask it.
330
+ logger.error(
331
+ "Trailing batch flush failed while handling %r: %s",
332
+ propagating,
333
+ flush_exc,
334
+ )
313
335
 
314
336
  return self
315
337
 
@@ -326,6 +348,15 @@ class EvaluationRunContext:
326
348
  _say(
327
349
  f" {green('✓')} Scored {resp.accepted} result{'s' if resp.accepted != 1 else ''}"
328
350
  )
351
+ if resp.failed_validation > 0:
352
+ # The engine accepts the batch but silently drops rows that fail its
353
+ # validation (typically empty output and no error) - say so, or those
354
+ # cases just vanish from the report.
355
+ _say(
356
+ f" {yellow('!')} {resp.failed_validation} result"
357
+ f"{'s' if resp.failed_validation != 1 else ''} failed validation "
358
+ "(empty output and no error) and did not get stored"
359
+ )
329
360
  logger.info(
330
361
  "Batch %s: accepted=%d duplicates=%d failed=%d",
331
362
  batch_id[:8],
@@ -415,8 +446,11 @@ class EvaluationRunContext:
415
446
  ``no_regression=True`` fails it when the average dropped more than ``tolerance``
416
447
  (default 0.5, judge scores are noisy) below the dataset's previous completed run.
417
448
  At least one check is required. On a multi-judge run, ``scorer`` (an additional
418
- scorer's id or name, e.g. ``scorer="Safety"``) gates that scorer's own average
419
- 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
449
+ judge scorer's id or name, e.g. ``scorer="Safety"``) gates that scorer's own average
450
+ instead of the primary's - "fail if Safety is low even when the average looks fine".
451
+ Only judge scorers resolve here: deterministic scorer-group members (pattern/code
452
+ kinds) have no per-run judge average, so naming one is rejected by the engine.
453
+ Prints a CI-log-friendly verdict and returns a
420
454
  :class:`GateResult` - the caller decides the exit code::
421
455
 
422
456
  report = client.evaluations.run(...).execute(my_agent).finalize()
@@ -469,6 +503,16 @@ class EvaluationRunContext:
469
503
  """Number of submitted results that have received a rating so far."""
470
504
  return self._live_stats.rated_count if self._live_stats else 0
471
505
 
506
+ @property
507
+ def skipped_count(self) -> int:
508
+ """Number of submitted results the judge could not score."""
509
+ return self._live_stats.skipped_count if self._live_stats else 0
510
+
511
+ @property
512
+ def failed_count(self) -> int:
513
+ """Number of submitted results that carried an error."""
514
+ return self._live_stats.failed_count if self._live_stats else 0
515
+
472
516
  @property
473
517
  def average_rating(self) -> Optional[float]:
474
518
  """Live average rating across all results scored so far. Populated as
@@ -509,7 +553,7 @@ class EvaluationRunContext:
509
553
  response's mode field for what actually ran.
510
554
  quality_mode: "quality_first" or "balanced" - how many items get a second/third judge.
511
555
  judges: 1-3 model ids from ``client.evaluations.list_models()``, e.g.
512
- ``["gpt-5.6-luna", "claude-opus-4-8"]``. Omit to let the engine score with its
556
+ ``["gpt-5.6-luna", "claude-opus-5"]``. Omit to let the engine score with its
513
557
  platform default model (a single judge, rather than the dashboard's 3-judge
514
558
  default - SDK runs are typically lighter-weight, quick-start evaluations).
515
559
  poll_interval: seconds between status checks while waiting.
@@ -529,16 +573,27 @@ class EvaluationRunContext:
529
573
  judges=judges,
530
574
  )
531
575
  deadline = time.monotonic() + timeout
532
- status = self._client.get_analysis_status(self._run.run_id)
533
- while not status.is_terminal and time.monotonic() < deadline:
534
- level = _ANALYSIS_LEVEL_LABELS.get(status.progress.current_level, "")
535
- spinner.update(
536
- f"Analyzing, {level + ' ' if level else ''}{status.progress.overall_percentage}%"
537
- )
576
+ status = None
577
+ while True:
578
+ try:
579
+ status = self._client.get_analysis_status(self._run.run_id)
580
+ except Exception as poll_exc:
581
+ # One transient status-poll failure (network blip, engine restart)
582
+ # must not abort the whole wait - the job keeps running
583
+ # server-side, so keep polling until the deadline.
584
+ logger.debug("Analysis status poll failed: %s", poll_exc)
585
+ if status is not None and status.is_terminal:
586
+ break
587
+ if time.monotonic() >= deadline:
588
+ break
589
+ if status is not None:
590
+ level = _ANALYSIS_LEVEL_LABELS.get(status.progress.current_level, "")
591
+ spinner.update(
592
+ f"Analyzing, {level + ' ' if level else ''}{status.progress.overall_percentage}%"
593
+ )
538
594
  time.sleep(poll_interval)
539
- status = self._client.get_analysis_status(self._run.run_id)
540
595
 
541
- if not status.is_terminal:
596
+ if status is None or not status.is_terminal:
542
597
  _say(f" {yellow('!')} Still running after {int(timeout)}s, check the dashboard for status")
543
598
  elif status.status == "failed":
544
599
  reason = status.failure_reason.message if status.failure_reason else "unknown error"
@@ -639,6 +694,36 @@ class EvaluationsRunner:
639
694
  script execution)."""
640
695
  return self._client.get_analysis_status(run_id)
641
696
 
697
+ # Run-lifecycle calls by id - the standalone forms of what run()/execute()/finalize()/
698
+ # analyze() drive for you, for scripts operating on a run created elsewhere.
699
+
700
+ def init_run(self, dataset_id: str, subject, **kwargs):
701
+ """Create a run row without executing anything - the standalone form of :meth:`run`.
702
+ Accepts the same kwargs as ``EvaluationsClient.init_run``."""
703
+ return self._client.init_run(dataset_id, subject, **kwargs)
704
+
705
+ def append_results(self, run_id: str, batch_id: str, results: list):
706
+ """Submit one batch of results to a run by id (scored synchronously server-side)."""
707
+ return self._client.append_results(run_id, batch_id, results)
708
+
709
+ def finalize_run(self, run_id: str) -> dict:
710
+ """Mark a run completed by id - the standalone form of
711
+ ``EvaluationRunContext.finalize()``."""
712
+ return self._client.finalize_run(run_id)
713
+
714
+ def analyze_run(self, run_id: str, **kwargs) -> dict:
715
+ """Start the LLM analysis of a finalized run by id; poll
716
+ :meth:`get_analysis_status`, then :meth:`get_report`."""
717
+ return self._client.analyze_run(run_id, **kwargs)
718
+
719
+ def get_report(self, run_id: str):
720
+ """The analyzed report for a run by id, once analysis has finished."""
721
+ return self._client.get_report(run_id)
722
+
723
+ def get_submitted_keys(self, run_id: str) -> list:
724
+ """Idempotency keys a run has already accepted - what execute() uses to resume."""
725
+ return self._client.get_submitted_keys(run_id)
726
+
642
727
  def gate_run(
643
728
  self,
644
729
  run_id: str,
@@ -153,7 +153,11 @@ def finish_llm_call(
153
153
  )
154
154
  return
155
155
 
156
- span = tracer.trace(name, metadata=metadata, framework=framework, model=model, session_id=session_id)
156
+ # A patched provider call outside any active span becomes its own root trace - it is a bare
157
+ # model call, so stamp it "llm" rather than leaving the kind unset.
158
+ span = tracer.trace(
159
+ name, metadata=metadata, framework=framework, model=model, session_id=session_id, span_kind="llm"
160
+ )
157
161
  span.__enter__()
158
162
  span._start = start_t
159
163
  span.input = input_repr
@@ -107,7 +107,10 @@ class AgentXAutoGenObserver:
107
107
  # explicit return/break/continue there would silently swallow any exception
108
108
  # propagating from agent_or_team.run() above (see crewai.py's kickoff() for the same
109
109
  # hazard spelled out in full).
110
- with self._tracer.trace(self._name, metadata=self._metadata, session_id=self._session_id) as span:
110
+ # span_kind="agent": the root of a standalone team/agent run is the agent run itself.
111
+ with self._tracer.trace(
112
+ self._name, metadata=self._metadata, session_id=self._session_id, span_kind="agent"
113
+ ) as span:
111
114
  span._start = start_t
112
115
  if error:
113
116
  span.set_error(error)