agentx-python 0.8.4__tar.gz → 0.8.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


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

Files changed (89) hide show
  1. {agentx_python-0.8.4/agentx_python.egg-info → agentx_python-0.8.5}/PKG-INFO +1 -1
  2. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/evaluations/client.py +18 -1
  3. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/evaluations/datasets.py +8 -0
  4. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/evaluations/models.py +3 -0
  5. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/evaluations/runner.py +145 -34
  6. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/monitor/client.py +56 -9
  7. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/monitor/judge_scorers.py +23 -5
  8. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/monitor/online_evaluators.py +7 -3
  9. agentx_python-0.8.5/agentx/monitor/review_queue.py +93 -0
  10. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/testing.py +3 -1
  11. agentx_python-0.8.5/agentx/version.py +1 -0
  12. {agentx_python-0.8.4 → agentx_python-0.8.5/agentx_python.egg-info}/PKG-INFO +1 -1
  13. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx_python.egg-info/SOURCES.txt +3 -0
  14. {agentx_python-0.8.4 → agentx_python-0.8.5}/tests/test_integrations.py +14 -0
  15. agentx_python-0.8.5/tests/test_review_queue.py +73 -0
  16. agentx_python-0.8.5/tests/test_runner_features.py +146 -0
  17. agentx_python-0.8.4/agentx/version.py +0 -1
  18. {agentx_python-0.8.4 → agentx_python-0.8.5}/LICENSE +0 -0
  19. {agentx_python-0.8.4 → agentx_python-0.8.5}/README.md +0 -0
  20. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/__init__.py +0 -0
  21. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/agentx.py +0 -0
  22. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/cli.py +0 -0
  23. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/evaluations/__init__.py +0 -0
  24. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/evaluations/_term.py +0 -0
  25. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/evaluations/adapters/__init__.py +0 -0
  26. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/evaluations/adapters/http_endpoint.py +0 -0
  27. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/evaluations/adapters/precomputed.py +0 -0
  28. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/evaluations/adapters/raw.py +0 -0
  29. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/evaluations/evaluation_settings.py +0 -0
  30. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/evaluations/prompts.py +0 -0
  31. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/evaluations/reporting.py +0 -0
  32. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/evaluations/results.py +0 -0
  33. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/evaluations/tool_schemas.py +0 -0
  34. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/evaluations/tracing.py +0 -0
  35. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/exceptions.py +0 -0
  36. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/export.py +0 -0
  37. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/feedback.py +0 -0
  38. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/integrations/__init__.py +0 -0
  39. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/integrations/_traced_call.py +0 -0
  40. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/integrations/anthropic.py +0 -0
  41. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/integrations/autogen.py +0 -0
  42. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/integrations/crewai.py +0 -0
  43. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/integrations/databricks.py +0 -0
  44. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/integrations/google_adk.py +0 -0
  45. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/integrations/google_genai.py +0 -0
  46. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/integrations/langchain.py +0 -0
  47. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/integrations/litellm.py +0 -0
  48. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/integrations/llamaindex.py +0 -0
  49. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/integrations/moveworks.py +0 -0
  50. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/integrations/openai.py +0 -0
  51. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/integrations/openai_agents.py +0 -0
  52. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/monitor/__init__.py +0 -0
  53. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/monitor/agents.py +0 -0
  54. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/monitor/models.py +0 -0
  55. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/monitor/patterns.py +0 -0
  56. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/monitor/profile.py +0 -0
  57. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/monitor/scorers.py +0 -0
  58. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/monitor/sessions.py +0 -0
  59. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/monitor/signals.py +0 -0
  60. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/outcomes.py +0 -0
  61. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/projects.py +0 -0
  62. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/py.typed +0 -0
  63. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/resources/__init__.py +0 -0
  64. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/resources/agent.py +0 -0
  65. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/resources/conversation.py +0 -0
  66. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/resources/workforce.py +0 -0
  67. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/traces.py +0 -0
  68. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/tracing/__init__.py +0 -0
  69. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/tracing/ci_types.py +0 -0
  70. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/tracing/eval_scope.py +0 -0
  71. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/tracing/ingest_client.py +0 -0
  72. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/tracing/tracer.py +0 -0
  73. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/util.py +0 -0
  74. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx_python.egg-info/dependency_links.txt +0 -0
  75. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx_python.egg-info/entry_points.txt +0 -0
  76. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx_python.egg-info/not-zip-safe +0 -0
  77. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx_python.egg-info/requires.txt +0 -0
  78. {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx_python.egg-info/top_level.txt +0 -0
  79. {agentx_python-0.8.4 → agentx_python-0.8.5}/setup.cfg +0 -0
  80. {agentx_python-0.8.4 → agentx_python-0.8.5}/setup.py +0 -0
  81. {agentx_python-0.8.4 → agentx_python-0.8.5}/tests/test_deep_dive_fixes.py +0 -0
  82. {agentx_python-0.8.4 → agentx_python-0.8.5}/tests/test_docs_match_sdk.py +0 -0
  83. {agentx_python-0.8.4 → agentx_python-0.8.5}/tests/test_eval_scope.py +0 -0
  84. {agentx_python-0.8.4 → agentx_python-0.8.5}/tests/test_integration.py +0 -0
  85. {agentx_python-0.8.4 → agentx_python-0.8.5}/tests/test_judge_scorers.py +0 -0
  86. {agentx_python-0.8.4 → agentx_python-0.8.5}/tests/test_pairwise.py +0 -0
  87. {agentx_python-0.8.4 → agentx_python-0.8.5}/tests/test_selfhost_analysis_fallback.py +0 -0
  88. {agentx_python-0.8.4 → agentx_python-0.8.5}/tests/test_span_tree.py +0 -0
  89. {agentx_python-0.8.4 → agentx_python-0.8.5}/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.4
3
+ Version: 0.8.5
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
@@ -60,6 +60,13 @@ class AgentXValidationError(AgentXEvaluationsError):
60
60
  pass
61
61
 
62
62
 
63
+ class EvaluationSubmissionError(AgentXEvaluationsError):
64
+ """A result batch could not be submitted (after one retry). The run is left unfinalized;
65
+ re-running execute() on the same context resumes past already-submitted cases."""
66
+
67
+ pass
68
+
69
+
63
70
  def _resolve_scorer_id(scorer_id: Optional[str], evaluation_settings_id: Optional[str]) -> Optional[str]:
64
71
  """One grader, two spellings: ``scorer_id`` is the post-consolidation name for what the wire
65
72
  still calls ``evaluationSettingsId`` (the ids are identical by design). Both kwargs are
@@ -297,10 +304,11 @@ class EvaluationsClient:
297
304
  python_version: Optional[str] = None,
298
305
  scorer_id: Optional[str] = None,
299
306
  evaluation_settings_id: Optional[str] = None,
307
+ split: Optional[str] = None,
300
308
  ) -> EvaluationRun:
301
309
  """``scorer_id`` names the LLM Judge Scorer grading this run (its id doubles as the
302
310
  wire's ``evaluationSettingsId``). ``evaluation_settings_id`` is the pre-consolidation
303
- alias and keeps working."""
311
+ alias and keeps working. ``split`` records the named case subset this run covers."""
304
312
  from agentx.version import VERSION
305
313
 
306
314
  grader_id = _resolve_scorer_id(scorer_id, evaluation_settings_id)
@@ -318,6 +326,8 @@ class EvaluationsClient:
318
326
  }
319
327
  if grader_id:
320
328
  payload["evaluationSettingsId"] = grader_id
329
+ if split:
330
+ payload["split"] = split
321
331
  data = self._request("POST", "/runs", json=self._with_workspace(payload))
322
332
  return EvaluationRun(**data)
323
333
 
@@ -436,6 +446,13 @@ class EvaluationsClient:
436
446
  data = self._request("GET", f"/runs/{run_id}/missing-results")
437
447
  return data if isinstance(data, list) else data.get("missing", [])
438
448
 
449
+ def get_submitted_keys(self, run_id: str) -> List[str]:
450
+ """Idempotency keys this run has already accepted - what execute() uses to resume a
451
+ crashed or interrupted run without re-running (and re-paying for) finished cases."""
452
+ data = self._request("GET", f"/runs/{run_id}/missing-results")
453
+ keys = data.get("submittedKeys", []) if isinstance(data, dict) else []
454
+ return [k for k in keys if isinstance(k, str)]
455
+
439
456
  # ------------------------------------------------------------------
440
457
  # Self-host analysis fallback
441
458
  #
@@ -105,9 +105,15 @@ class DatasetBuilder:
105
105
  expected_tools: Optional[List[str]] = None,
106
106
  trajectory_match_mode: str = "strict",
107
107
  expected_retrieval_context: Optional[Union[str, List[str]]] = None,
108
+ splits: Optional[List[str]] = None,
108
109
  ) -> "DatasetBuilder":
109
110
  """Add a case. `judge_guideline` is optional grading guidance specific to this question.
110
111
 
112
+ `splits` tags this case with named subsets (e.g. ``["smoke"]``): a run started with
113
+ ``client.evaluations.run(dataset_id, subject, split="smoke")`` executes only the tagged
114
+ cases (original case indexes are preserved, so per-case comparisons still line up with
115
+ full runs). An untagged case belongs to no split and only runs in full runs.
116
+
111
117
  `expected_tools` declares the tool calls a correct run of this case should make. When a
112
118
  result links its trace (return `{"output": ..., "trace_id": span.trace_id}` from the
113
119
  agent function), the engine matches the trace's actual tool-call sequence against it and
@@ -149,6 +155,8 @@ class DatasetBuilder:
149
155
  main["expectedTrajectory"] = {"tools": expected_tools, "mode": trajectory_match_mode}
150
156
  if expected_retrieval_context:
151
157
  main["expectedRetrievalContext"] = expected_retrieval_context
158
+ if splits:
159
+ main["splits"] = splits
152
160
  self._payload["questions"].append(
153
161
  {
154
162
  "main_question": main,
@@ -52,6 +52,9 @@ class TestCase(BaseModel):
52
52
  expected_delegations: Optional[List[str]] = Field(default=None, alias="expectedDelegations")
53
53
  judge_guideline: Optional[str] = Field(default=None, alias="judgeGuideline")
54
54
  smoke_test: Optional[SmokeTestSettings] = Field(default=None, alias="smokeTest")
55
+ # Named subsets this case belongs to (e.g. ["smoke"], ["full", "regression"]).
56
+ # ``run(dataset_id, split="smoke")`` runs only cases tagged with that split.
57
+ splits: Optional[List[str]] = None
55
58
 
56
59
  class Config:
57
60
  populate_by_name = True
@@ -4,12 +4,12 @@ import logging
4
4
  import os
5
5
  import time
6
6
  import uuid
7
- from typing import Any, Callable, Dict, List, Optional, Set, Union
7
+ from typing import Any, Callable, Dict, Iterator, List, Optional, Set, Union
8
8
 
9
9
  from agentx.evaluations.adapters.raw import RawCallableAdapter
10
10
  from agentx.evaluations.adapters.precomputed import PrecomputedAdapter
11
11
  from agentx.evaluations.adapters.http_endpoint import HttpEndpointAdapter
12
- from agentx.evaluations.client import EvaluationsClient
12
+ from agentx.evaluations.client import EvaluationsClient, EvaluationSubmissionError
13
13
  from agentx.evaluations.models import (
14
14
  AnalysisStatus,
15
15
  Dataset,
@@ -96,11 +96,14 @@ class EvaluationRunContext:
96
96
  run: EvaluationRun,
97
97
  subject: EvaluationSubject,
98
98
  evaluation_settings: Optional[EvaluationSettings] = None,
99
+ split: Optional[str] = None,
99
100
  ):
100
101
  self._client = client
101
102
  self._dataset = dataset
102
103
  self._run = run
103
104
  self._subject = subject
105
+ # Named case subset for this run - only cases tagged with it are built/executed.
106
+ self._split = split
104
107
  # When set, this run was started with an independently chosen grading
105
108
  # config (evaluation_settings_id) - its fields take precedence over the
106
109
  # dataset's own for anything execution-time reads (see _build_cases).
@@ -118,25 +121,63 @@ class EvaluationRunContext:
118
121
  # Step 1: execute
119
122
  # ------------------------------------------------------------------
120
123
 
121
- def execute(self, adapter: AdapterLike) -> "EvaluationRunContext":
124
+ def execute(
125
+ self,
126
+ adapter: AdapterLike,
127
+ concurrency: int = 1,
128
+ reuse_outputs_from: Optional[str] = None,
129
+ ) -> "EvaluationRunContext":
122
130
  """Run all cases locally and submit batches to AgentX.
123
131
 
124
132
  The whole loop runs inside the eval-run scope (tracing/eval_scope.py): any trace the
125
133
  agent function creates is stamped source="eval-run" + monitor=False automatically, so
126
134
  eval traffic never skews production monitoring and no one has to remember a flag.
135
+
136
+ ``concurrency`` > 1 runs the agent callable across a thread pool (results are still
137
+ submitted in case order, and the eval-run scope is propagated into the workers).
138
+ ``reuse_outputs_from`` replays a previous run's recorded outputs for cases whose query
139
+ text is unchanged instead of re-running (and re-paying for) the agent - the judge still
140
+ re-scores them, which makes iterating on scorers cheap. Changed or new cases run
141
+ normally.
127
142
  """
128
143
  from agentx.tracing.eval_scope import enter_eval_run, exit_eval_run
129
144
 
130
145
  scope_token = enter_eval_run(self._run.run_id)
131
146
  try:
132
- return self._execute_inner(adapter)
147
+ return self._execute_inner(adapter, concurrency=concurrency, reuse_outputs_from=reuse_outputs_from)
133
148
  finally:
134
149
  exit_eval_run(scope_token)
135
150
 
136
- def _execute_inner(self, adapter: AdapterLike) -> "EvaluationRunContext":
151
+ def _fetch_reusable_outputs(self, run_id: str) -> Dict[tuple, str]:
152
+ """(query, run_number, is_smoke_variant) -> output text, from a previous run's rows.
153
+ Keyed on the query TEXT so a reworded case never silently reuses a stale answer."""
154
+ try:
155
+ prior = self._client.get_run(run_id)
156
+ except Exception as exc:
157
+ logger.warning("reuse_outputs_from: could not load run %s (%s) - running everything", run_id, exc)
158
+ return {}
159
+ reusable: Dict[tuple, str] = {}
160
+ for row in prior.get("results", []) or []:
161
+ if row.get("error") or row.get("status") == "failed":
162
+ continue
163
+ output = (row.get("output") or {}).get("text")
164
+ query = (row.get("input") or {}).get("query")
165
+ if not output or not query:
166
+ continue
167
+ key = (query, row.get("runNumber") or 1, bool(row.get("isSmokeTestVariant")))
168
+ reusable[key] = output
169
+ return reusable
170
+
171
+ def _execute_inner(
172
+ self,
173
+ adapter: AdapterLike,
174
+ concurrency: int = 1,
175
+ reuse_outputs_from: Optional[str] = None,
176
+ ) -> "EvaluationRunContext":
137
177
  normalized = _wrap_adapter(adapter)
138
- cases = _build_cases(self._dataset, self._run, self._evaluation_settings)
178
+ cases = _build_cases(self._dataset, self._run, self._evaluation_settings, split=self._split)
139
179
  max_batch = self._run.limits.max_batch_size
180
+ reusable = self._fetch_reusable_outputs(reuse_outputs_from) if reuse_outputs_from else {}
140
181
 
141
182
  # Banner
142
183
  sep = "─" * 60
@@ -144,7 +185,7 @@ class EvaluationRunContext:
144
185
  framework = self._subject.framework or "custom"
145
186
  runtime = self._subject.runtime or "local"
146
187
  display = self._subject.display_name or ""
147
- n_q = len(self._dataset.questions)
188
+ n_q = len({c.question_index for c in cases if not c.is_smoke_test_variant})
148
189
  n_r = (
149
190
  self._evaluation_settings.number_of_requests
150
191
  if self._evaluation_settings
@@ -171,6 +212,45 @@ class EvaluationRunContext:
171
212
  batch: List[EvaluationResult] = []
172
213
  total = len(cases)
173
214
 
215
+ def produce(case: EvaluationCase) -> EvaluationResult:
216
+ # Cached replay: same query text at the same repetition reuses the recorded output
217
+ # (the server still re-scores it with THIS run's grading config).
218
+ cached = reusable.get((case.query, case.run_number, case.is_smoke_test_variant))
219
+ if cached is not None:
220
+ return normalize_result(
221
+ case, {"output": cached, "metadata": {"reusedFromRun": reuse_outputs_from}}
222
+ )
223
+ return normalized(case)
224
+
225
+ if concurrency > 1:
226
+ import concurrent.futures
227
+ import contextvars
228
+
229
+ def in_scope(case: EvaluationCase) -> EvaluationResult:
230
+ # ContextVars (the eval-run scope) do not cross thread boundaries on their own -
231
+ # each worker task runs inside a copy of the submitting thread's context so the
232
+ # agent's traces still get stamped source="eval-run".
233
+ return contextvars.copy_context().run(produce, case)
234
+
235
+ pending = [
236
+ case
237
+ for case in cases
238
+ if _idem_key(self._run.run_id, case.case_id, case.run_number) not in already_done
239
+ ]
240
+ executor = concurrent.futures.ThreadPoolExecutor(max_workers=concurrency)
241
+ # map() yields in submission order, so batching/submission below stays deterministic.
242
+ mapped = executor.map(in_scope, pending)
243
+
244
+ def ordered() -> "Iterator[EvaluationResult]":
245
+ try:
246
+ yield from mapped
247
+ finally:
248
+ executor.shutdown(wait=True)
249
+
250
+ results_iter = ordered()
251
+ else:
252
+ results_iter = None # sequential path below produces inline
253
+
174
254
  for idx, case in enumerate(cases, start=1):
175
255
  idem_key = _idem_key(self._run.run_id, case.case_id, case.run_number)
176
256
 
@@ -179,7 +259,7 @@ class EvaluationRunContext:
179
259
  _print_progress(idx, total, case, skipped=True)
180
260
  continue
181
261
 
182
- result = normalized(case)
262
+ result = next(results_iter) if results_iter is not None else produce(case)
183
263
  result.idempotency_key = idem_key
184
264
  # Tag the result with the case's model so the server can group it into
185
265
  # the Sovereignty & Portability matrix (the callable may also set it).
@@ -207,31 +287,46 @@ class EvaluationRunContext:
207
287
  batch_id = str(uuid.uuid4())
208
288
  n = len(batch)
209
289
  with Spinner(f"Scoring - AI is rating {n} result{'s' if n != 1 else ''}"):
210
- try:
211
- resp = self._client.append_results(self._run.run_id, batch_id, batch)
212
- if resp.live_statistics is not None:
213
- self._live_stats = resp.live_statistics
214
- _say(
215
- f" {green('✓')} Scored {resp.accepted} result{'s' if resp.accepted != 1 else ''}"
216
- )
217
- logger.info(
218
- "Batch %s: accepted=%d duplicates=%d failed=%d",
219
- batch_id[:8],
220
- resp.accepted,
221
- resp.duplicates,
222
- resp.failed_validation,
223
- )
224
- except Exception as exc:
225
- _say(f" {red('✗')} Scoring failed: {dim(str(exc))}")
226
- logger.error("Failed to submit batch %s: %s", batch_id[:8], exc)
290
+ last_exc: Optional[Exception] = None
291
+ for attempt in (1, 2):
292
+ try:
293
+ resp = self._client.append_results(self._run.run_id, batch_id, batch)
294
+ if resp.live_statistics is not None:
295
+ self._live_stats = resp.live_statistics
296
+ _say(
297
+ f" {green('✓')} Scored {resp.accepted} result{'s' if resp.accepted != 1 else ''}"
298
+ )
299
+ logger.info(
300
+ "Batch %s: accepted=%d duplicates=%d failed=%d",
301
+ batch_id[:8],
302
+ resp.accepted,
303
+ resp.duplicates,
304
+ resp.failed_validation,
305
+ )
306
+ return
307
+ except Exception as exc:
308
+ last_exc = exc
309
+ if attempt == 1:
310
+ logger.warning("Batch %s submission failed, retrying once: %s", batch_id[:8], exc)
311
+ # A batch that cannot be submitted must FAIL the run, not print a red line and carry on:
312
+ # execute() used to finish "successfully" having uploaded nothing. Failing fast also
313
+ # stops paying for agent calls whose results would hit the same broken engine; a
314
+ # re-execute() of this context resumes past everything already accepted (idempotency
315
+ # keys are deterministic and the engine returns the submitted set).
316
+ _say(f" {red('✗')} Scoring failed: {dim(str(last_exc))}")
317
+ logger.error("Failed to submit batch %s after retry: %s", batch_id[:8], last_exc)
318
+ raise EvaluationSubmissionError(
319
+ f"Failed to submit a batch of {n} result(s) to the engine after a retry: {last_exc}. "
320
+ "The run was left unfinalized; re-running execute() resumes past already-submitted cases."
321
+ ) from last_exc
227
322
 
228
323
  def _fetch_submitted_keys(self) -> Set[str]:
324
+ """Keys already accepted by this run - the engine's /missing-results route returns them
325
+ so a re-execute() after a crash skips (and never re-pays for) finished cases."""
229
326
  try:
230
- missing = self._client.get_missing_results(self._run.run_id)
231
- # missing-results returns cases NOT yet submitted - we want the inverse
232
- # but if the endpoint isn't live yet, just return empty set
233
- return set()
327
+ return set(self._client.get_submitted_keys(self._run.run_id))
234
328
  except Exception:
329
+ # Older engines without the route: no resume, identical to the historical behavior.
235
330
  return set()
236
331
 
237
332
  # ------------------------------------------------------------------
@@ -517,11 +612,16 @@ class EvaluationsRunner:
517
612
  subject: Union[Dict[str, Any], EvaluationSubject],
518
613
  scorer_id: Optional[str] = None,
519
614
  evaluation_settings_id: Optional[str] = None,
615
+ split: Optional[str] = None,
520
616
  ) -> EvaluationRunContext:
521
617
  """Start a run of ``dataset_id`` against ``subject``. Pass ``scorer_id`` (an LLM Judge
522
618
  Scorer's id, e.g. from ``client.monitor.judge_scorers``) to grade with a specific
523
619
  scorer instead of the dataset's default. ``evaluation_settings_id`` is the
524
- pre-consolidation alias for the same id and keeps working."""
620
+ pre-consolidation alias for the same id and keeps working.
621
+
622
+ ``split`` runs only the cases tagged with that named subset (``add_case(...,
623
+ splits=["smoke"])``) - the cheap-PR-run vs nightly-full-run workflow. Original case
624
+ indexes are preserved so per-case comparisons line up with full runs."""
525
625
  from agentx.evaluations.client import _resolve_scorer_id
526
626
 
527
627
  if isinstance(subject, dict):
@@ -532,18 +632,24 @@ class EvaluationsRunner:
532
632
  evaluation_settings = (
533
633
  self._client.get_evaluation_settings(grader_id) if grader_id else None
534
634
  )
535
- run = self._client.init_run(dataset_id, subject, scorer_id=grader_id)
635
+ run = self._client.init_run(dataset_id, subject, scorer_id=grader_id, split=split)
636
+ case_count = (
637
+ sum(1 for q in dataset.questions if split in (q.main_question.splits or []))
638
+ if split
639
+ else len(dataset.questions)
640
+ )
536
641
  logger.info(
537
- "Started evaluation run %s on dataset %s (%d case(s), %d repetition(s))",
642
+ "Started evaluation run %s on dataset %s (%d case(s)%s, %d repetition(s))",
538
643
  run.run_id,
539
644
  dataset_id,
540
- len(dataset.questions),
645
+ case_count,
646
+ f' in split "{split}"' if split else "",
541
647
  evaluation_settings.number_of_requests
542
648
  if evaluation_settings
543
649
  else dataset.number_of_requests,
544
650
  )
545
651
  return EvaluationRunContext(
546
- self._client, dataset, run, subject, evaluation_settings=evaluation_settings
652
+ self._client, dataset, run, subject, evaluation_settings=evaluation_settings, split=split
547
653
  )
548
654
 
549
655
 
@@ -568,6 +674,7 @@ def _build_cases(
568
674
  dataset: Dataset,
569
675
  run: EvaluationRun,
570
676
  evaluation_settings: Optional[EvaluationSettings] = None,
677
+ split: Optional[str] = None,
571
678
  ) -> List[EvaluationCase]:
572
679
  cases: List[EvaluationCase] = []
573
680
  # When an independent evaluation_settings was chosen (evaluation_settings_id
@@ -596,6 +703,10 @@ def _build_cases(
596
703
  }
597
704
  for q_idx, question in enumerate(dataset.questions):
598
705
  mq = question.main_question
706
+ # Split filtering preserves q_idx: a "smoke" run's case 7 is the same case 7 a full run
707
+ # scores, so per-case comparisons line up across the two.
708
+ if split and split not in (mq.splits or []):
709
+ continue
599
710
  for run_num in range(1, n_runs + 1):
600
711
  for model in models:
601
712
  suffix = f"::{model}" if model else ""
@@ -39,6 +39,35 @@ class AgentXValidationError(AgentXMonitorError):
39
39
  pass
40
40
 
41
41
 
42
+ class CalibrationSummary(dict):
43
+ """Judge Calibration numbers (dict subclass, so existing key access keeps working).
44
+ Properties mirror the wire's exact camelCase keys."""
45
+
46
+ @property
47
+ def compared_count(self) -> int:
48
+ return int(self.get("comparedCount") or 0)
49
+
50
+ @property
51
+ def agreement_rate(self):
52
+ return self.get("agreementRate")
53
+
54
+ @property
55
+ def false_positive_rate(self):
56
+ return self.get("falsePositiveRate")
57
+
58
+ @property
59
+ def false_negative_rate(self):
60
+ return self.get("falseNegativeRate")
61
+
62
+ @property
63
+ def reported_count(self) -> int:
64
+ return int(self.get("reportedCount") or 0)
65
+
66
+ @property
67
+ def review_label_count(self) -> int:
68
+ return int(self.get("reviewLabelCount") or 0)
69
+
70
+
42
71
  class MonitorClient:
43
72
  """Low-level HTTP client for the Monitor API (``/monitor``). Accessed via
44
73
  ``client.monitor`` on the top-level :class:`agentx.AgentX` instance; most callers
@@ -82,6 +111,11 @@ class MonitorClient:
82
111
 
83
112
  self.patterns = MonitorPatternClient(self)
84
113
  self.signals = MonitorSignalClient(self)
114
+ from agentx.monitor.review_queue import ReviewQueueClient
115
+
116
+ # The human-review queue (list / queue / label / dismiss) - what makes the
117
+ # label-and-calibrate loop scriptable instead of dashboard-only.
118
+ self.review_queue = ReviewQueueClient(self)
85
119
  from agentx.monitor.scorers import ScorersClient
86
120
  # Scorers-catalog administration as code: template enable/disable, code/external scorer
87
121
  # CRUD and dry runs - full parity with the dashboard's Scorers page (P1.3).
@@ -230,15 +264,19 @@ class MonitorClient:
230
264
  plus deltas vs the prior window and the run-outcome breakdown."""
231
265
  return self._request("GET", "/kpis", params={"window": window})
232
266
 
233
- def calibration(self, window: str = "7d") -> dict:
267
+ def calibration(self, window: str = "7d") -> "CalibrationSummary":
234
268
  """Project-level judge calibration over a window ("24h", "7d", or "30d"): how often
235
269
  AgentX's own verdicts agreed with real-world ground truth reported later (ops outcomes
236
- via ``client.outcomes`` and end-user downvotes). Returns the dashboard's Judge
237
- Calibration numbers: compared count, agreement, falsePositiveRate, falseNegativeRate.
238
- Per-evaluator calibration lives on ``client.monitor.online_evaluators.calibration``."""
239
- return self._request(
240
- "GET", "/agent-monitoring/calibration",
241
- base=self._api_root(), params={"window": window},
270
+ via ``client.outcomes``, end-user downvotes, and human review labels). Returns the
271
+ dashboard's Judge Calibration numbers with these exact keys: ``comparedCount``,
272
+ ``agreementRate``, ``falsePositiveRate``, ``falseNegativeRate`` (plus
273
+ ``reportedCount``/``reviewLabelCount``/``noVerdictCount``). Per-scorer calibration
274
+ lives on ``client.monitor.judge_scorers.calibration(scorer_id)``."""
275
+ return CalibrationSummary(
276
+ self._request(
277
+ "GET", "/agent-monitoring/calibration",
278
+ base=self._api_root(), params={"window": window},
279
+ )
242
280
  )
243
281
 
244
282
  # ------------------------------------------------------------------
@@ -352,10 +390,19 @@ class MonitorClient:
352
390
  base=self._api_root(), json={**criteria, "window": window}, timeout=600,
353
391
  )
354
392
 
355
- def publish_online_evaluator_tuning(self, evaluator_id: str, criteria: dict) -> dict:
393
+ def publish_online_evaluator_tuning(
394
+ self, evaluator_id: str, criteria: dict, *, validation: Optional[dict] = None, force: bool = False
395
+ ) -> dict:
396
+ # The engine gates publish on validation provenance (and refuses a measured regression)
397
+ # unless forced - see judge_scorers.publish_tuning for the full story.
398
+ payload = dict(criteria)
399
+ if validation is not None:
400
+ payload["validation"] = validation
401
+ if force:
402
+ payload["force"] = True
356
403
  return self._request(
357
404
  "POST", f"/agent-monitoring/online-evaluators/{evaluator_id}/tune/publish",
358
- base=self._api_root(), json=criteria, timeout=60,
405
+ base=self._api_root(), json=payload, timeout=60,
359
406
  )
360
407
 
361
408
  def update_profile(self, agent_id: str, payload: dict) -> MonitorProfile:
@@ -280,12 +280,30 @@ class JudgeScorersClient:
280
280
  timeout=600,
281
281
  )
282
282
 
283
- def publish_tuning(self, scorer_id: str, criteria: Dict[str, Any]) -> dict:
283
+ def publish_tuning(
284
+ self,
285
+ scorer_id: str,
286
+ criteria: Dict[str, Any],
287
+ *,
288
+ validation: Optional[Dict[str, Any]] = None,
289
+ force: bool = False,
290
+ ) -> dict:
284
291
  """Write tuned criteria onto the scorer's rubric - it applies everywhere the scorer is
285
- used: online scoring, offline dataset runs, and the playground."""
286
- return self._request(
287
- "POST", f"/online-evaluators/{self._profile_id(scorer_id)}/tune/publish", json=dict(criteria)
288
- )
292
+ used: online scoring, offline dataset runs, and the playground.
293
+
294
+ The engine gates publish on provenance: pass ``validation`` (the dict returned by
295
+ ``validate_tuning``, or at least its ``verdict``/``netAgreementGain``) so the version
296
+ history records what the change measurably did; a ``regressed`` verdict is refused.
297
+ ``force=True`` publishes without (or despite) validation - deliberate escape hatch."""
298
+ payload = dict(criteria)
299
+ if validation is not None:
300
+ payload["validation"] = {
301
+ "verdict": validation.get("verdict"),
302
+ "netAgreementGain": validation.get("netAgreementGain"),
303
+ }
304
+ if force:
305
+ payload["force"] = True
306
+ return self._request("POST", f"/online-evaluators/{self._profile_id(scorer_id)}/tune/publish", json=payload)
289
307
 
290
308
  def ratings(self, scorer_id: str, window: str = "7d") -> "List[OnlineEvaluatorRatingPoint]":
291
309
  """Bucketed average-rating-over-time for this scorer's live checks - same typed points
@@ -157,9 +157,13 @@ class MonitorOnlineEvaluatorClient:
157
157
  is {acceptanceCriteria, rejectionCriteria, evaluationCriteria} from tune()."""
158
158
  return self._client.validate_online_evaluator_tuning(evaluator_id, criteria, window)
159
159
 
160
- def publish_tuning(self, evaluator_id: str, criteria: dict) -> dict:
161
- """Publish tuned criteria onto the evaluator's config (the human-approval step)."""
162
- return self._client.publish_online_evaluator_tuning(evaluator_id, criteria)
160
+ def publish_tuning(
161
+ self, evaluator_id: str, criteria: dict, *, validation: Optional[dict] = None, force: bool = False
162
+ ) -> dict:
163
+ """Publish tuned criteria onto the evaluator's config (the human-approval step).
164
+ Pass ``validation`` (the ``validate_tuning`` result) - the engine refuses an unvalidated
165
+ publish, and a ``regressed`` verdict, unless ``force=True``."""
166
+ return self._client.publish_online_evaluator_tuning(evaluator_id, criteria, validation=validation, force=force)
163
167
 
164
168
  def ratings(self, evaluator_id: str, window: str = "7d") -> List[OnlineEvaluatorRatingPoint]:
165
169
  """Bucketed average-rating-over-time for this evaluator. ``window`` is one of
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from typing import Any, Dict, List, Optional, TYPE_CHECKING
5
+
6
+ if TYPE_CHECKING:
7
+ from agentx.monitor.client import MonitorClient
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class ReviewQueueItem(dict):
13
+ """Wire object for one human-review item (dict subclass so unknown fields round-trip)."""
14
+
15
+ @property
16
+ def id(self) -> str:
17
+ return self["_id"]
18
+
19
+ @property
20
+ def trace_id(self) -> Optional[str]:
21
+ return self.get("traceId")
22
+
23
+ @property
24
+ def status(self) -> Optional[str]:
25
+ return self.get("status")
26
+
27
+ @property
28
+ def label(self) -> Optional[str]:
29
+ return self.get("label")
30
+
31
+ @property
32
+ def judge_score_at_queue(self) -> Optional[float]:
33
+ return self.get("judgeScoreAtQueue")
34
+
35
+
36
+ class ReviewQueueClient:
37
+ """Surfaced as ``client.monitor.review_queue``: the human-review queue behind the dashboard's
38
+ Review tab, scriptable - so the label-and-calibrate loop (sample traces, label them
39
+ good/bad, optionally re-score) can run end to end from code. Labels feed judge calibration
40
+ and become judge-tuning evidence.
41
+
42
+ The engine refuses duplicates (409, a trace already pending) and a full queue (429, pending
43
+ cap reached); both surface as raised errors with the engine's reason.
44
+ """
45
+
46
+ def __init__(self, client: "MonitorClient"):
47
+ self._client = client
48
+
49
+ def list(self, status: Optional[str] = None, source: Optional[str] = None, limit: int = 100) -> List[ReviewQueueItem]:
50
+ """Queue items, newest first. ``status``: "pending" | "labeled" | "skipped" | "all"
51
+ (server default: pending). ``source``: "manual" | "rule" | "all"."""
52
+ params: Dict[str, Any] = {"limit": limit}
53
+ if status is not None:
54
+ params["status"] = status
55
+ if source is not None:
56
+ params["source"] = source
57
+ data = self._client._request("GET", "/agent-monitoring/review-queue", base=self._client._api_root(), params=params)
58
+ return [ReviewQueueItem(item) for item in data.get("items", [])]
59
+
60
+ def queue(self, trace_id: str, note: Optional[str] = None) -> ReviewQueueItem:
61
+ """Send a trace to human review (the SDK-side twin of the dashboard's "Send to review")."""
62
+ payload: Dict[str, Any] = {"traceId": trace_id, "source": "manual"}
63
+ if note:
64
+ payload["note"] = note
65
+ data = self._client._request("POST", "/agent-monitoring/review-queue", base=self._client._api_root(), json=payload)
66
+ return ReviewQueueItem(data.get("item", data))
67
+
68
+ def label(
69
+ self,
70
+ item_id: str,
71
+ label: str,
72
+ *,
73
+ corrected_score: Optional[float] = None,
74
+ note: Optional[str] = None,
75
+ ) -> ReviewQueueItem:
76
+ """Record the human verdict on a queued item. ``label`` is "good" or "bad";
77
+ ``corrected_score`` (0-10) optionally re-scores the judge's own rating for the trace -
78
+ the pair that calibration consumes."""
79
+ if label not in ("good", "bad"):
80
+ raise ValueError('label must be "good" or "bad"')
81
+ payload: Dict[str, Any] = {"label": label}
82
+ if corrected_score is not None:
83
+ payload["correctedScore"] = corrected_score
84
+ if note is not None:
85
+ payload["note"] = note
86
+ data = self._client._request(
87
+ "PATCH", f"/agent-monitoring/review-queue/{item_id}", base=self._client._api_root(), json=payload
88
+ )
89
+ return ReviewQueueItem(data.get("item", data))
90
+
91
+ def dismiss(self, item_id: str) -> None:
92
+ """Remove an item from the queue without a verdict (does not feed calibration)."""
93
+ self._client._request("DELETE", f"/agent-monitoring/review-queue/{item_id}", base=self._client._api_root())
@@ -46,7 +46,9 @@ def _format_failures(gate: Any) -> str:
46
46
  for check in checks:
47
47
  get = check.get if isinstance(check, dict) else lambda k, d=None: getattr(check, k, d)
48
48
  status = "PASS" if get("passed") else "FAIL"
49
- lines.append(f" [{status}] {get('name', 'check')}: {get('detail', '')}")
49
+ # The engine names each check under the key "check" ("fail-under" / "no-regression");
50
+ # "name" is kept as a fallback for any older payload shape.
51
+ lines.append(f" [{status}] {get('check') or get('name', 'check')}: {get('detail', '')}")
50
52
  average = getattr(gate, "average_rating", None)
51
53
  if average is not None:
52
54
  lines.append(f" average rating: {average}")
@@ -0,0 +1 @@
1
+ VERSION = "0.8.5"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentx-python
3
- Version: 0.8.4
3
+ Version: 0.8.5
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
@@ -52,6 +52,7 @@ agentx/monitor/models.py
52
52
  agentx/monitor/online_evaluators.py
53
53
  agentx/monitor/patterns.py
54
54
  agentx/monitor/profile.py
55
+ agentx/monitor/review_queue.py
55
56
  agentx/monitor/scorers.py
56
57
  agentx/monitor/sessions.py
57
58
  agentx/monitor/signals.py
@@ -78,6 +79,8 @@ tests/test_integration.py
78
79
  tests/test_integrations.py
79
80
  tests/test_judge_scorers.py
80
81
  tests/test_pairwise.py
82
+ tests/test_review_queue.py
83
+ tests/test_runner_features.py
81
84
  tests/test_selfhost_analysis_fallback.py
82
85
  tests/test_span_tree.py
83
86
  tests/test_testing.py
@@ -741,6 +741,16 @@ def _reset_litellm_callback_state(litellm) -> None:
741
741
  litellm._async_input_callback = []
742
742
 
743
743
 
744
+ def _wait_for_send(tracer, timeout: float = 5.0) -> None:
745
+ """LiteLLM dispatches success/failure callbacks on a background thread (or a
746
+ fire-and-forget task) even for sync completions, so asserting on tracer._send
747
+ immediately after the call races the dispatcher - reliably losing under a loaded
748
+ full-suite run. Poll until the mock is called instead of sleeping a fixed beat."""
749
+ deadline = time.time() + timeout
750
+ while time.time() < deadline and not tracer._send.called:
751
+ time.sleep(0.02)
752
+
753
+
744
754
  def test_litellm_sync_completion_traces_call():
745
755
  litellm = pytest.importorskip("litellm")
746
756
  _reset_litellm_callback_state(litellm)
@@ -757,6 +767,7 @@ def test_litellm_sync_completion_traces_call():
757
767
  litellm.callbacks = []
758
768
 
759
769
  assert response.choices[0].message.content == "Hello there!"
770
+ _wait_for_send(tracer)
760
771
  tracer._send.assert_called_once()
761
772
  _, kwargs = tracer._send.call_args
762
773
  assert kwargs["output"] == "Hello there!"
@@ -790,6 +801,7 @@ def test_litellm_async_completion_traces_call():
790
801
  litellm.callbacks = []
791
802
 
792
803
  assert response.choices[0].message.content == "Hello async!"
804
+ _wait_for_send(tracer)
793
805
  tracer._send.assert_called_once()
794
806
  _, kwargs = tracer._send.call_args
795
807
  assert kwargs["output"] == "Hello async!"
@@ -818,6 +830,7 @@ def test_litellm_streaming_traces_aggregated_response():
818
830
  litellm.callbacks = []
819
831
 
820
832
  assert len(chunks) > 1
833
+ _wait_for_send(tracer)
821
834
  tracer._send.assert_called_once()
822
835
  _, kwargs = tracer._send.call_args
823
836
  assert kwargs["output"] == "Hello streamed!"
@@ -839,6 +852,7 @@ def test_litellm_failure_records_error():
839
852
  finally:
840
853
  litellm.callbacks = []
841
854
 
855
+ _wait_for_send(tracer)
842
856
  tracer._send.assert_called_once()
843
857
  _, kwargs = tracer._send.call_args
844
858
  assert "boom" in kwargs["error"]
@@ -0,0 +1,73 @@
1
+ """Unit tests for client.monitor.review_queue (list / queue / label / dismiss) - wire-level,
2
+ no engine required. The engine-side contract is pinned by its review-queue routes."""
3
+
4
+ from typing import Any, Dict, List
5
+
6
+ import pytest
7
+
8
+ from agentx.monitor.review_queue import ReviewQueueClient, ReviewQueueItem
9
+
10
+
11
+ class FakeMonitorClient:
12
+ def __init__(self, responses: List[Any]):
13
+ self.calls: List[Dict[str, Any]] = []
14
+ self._responses = responses
15
+
16
+ def _api_root(self) -> str:
17
+ return "http://engine:4700/api/v1"
18
+
19
+ def _request(self, method: str, path: str, base: str = "", **kwargs: Any) -> Any:
20
+ self.calls.append({"method": method, "path": path, "base": base, **kwargs})
21
+ return self._responses.pop(0) if self._responses else {}
22
+
23
+
24
+ def test_list_hits_the_queue_with_filters():
25
+ fake = FakeMonitorClient([{"items": [{"_id": "r1", "traceId": "t1", "status": "pending"}], "pending": 1}])
26
+ items = ReviewQueueClient(fake).list(status="pending", source="rule", limit=25) # type: ignore[arg-type]
27
+ call = fake.calls[0]
28
+ assert call["method"] == "GET"
29
+ assert call["path"] == "/agent-monitoring/review-queue"
30
+ assert call["base"] == "http://engine:4700/api/v1"
31
+ assert call["params"] == {"limit": 25, "status": "pending", "source": "rule"}
32
+ assert isinstance(items[0], ReviewQueueItem)
33
+ assert items[0].id == "r1"
34
+ assert items[0].trace_id == "t1"
35
+
36
+
37
+ def test_queue_sends_trace_and_note():
38
+ fake = FakeMonitorClient([{"item": {"_id": "r2", "traceId": "t9"}}])
39
+ item = ReviewQueueClient(fake).queue("t9", note="looks off") # type: ignore[arg-type]
40
+ call = fake.calls[0]
41
+ assert call["method"] == "POST"
42
+ assert call["json"] == {"traceId": "t9", "source": "manual", "note": "looks off"}
43
+ assert item.id == "r2"
44
+
45
+
46
+ def test_label_validates_and_sends_the_calibration_pair():
47
+ fake = FakeMonitorClient([{"item": {"_id": "r3", "label": "bad", "judgeScoreAtQueue": 8.0}}])
48
+ client = ReviewQueueClient(fake) # type: ignore[arg-type]
49
+ item = client.label("r3", "bad", corrected_score=2, note="hallucinated policy")
50
+ call = fake.calls[0]
51
+ assert call["method"] == "PATCH"
52
+ assert call["path"] == "/agent-monitoring/review-queue/r3"
53
+ assert call["json"] == {"label": "bad", "correctedScore": 2, "note": "hallucinated policy"}
54
+ assert item.label == "bad"
55
+ assert item.judge_score_at_queue == 8.0
56
+
57
+ with pytest.raises(ValueError):
58
+ client.label("r3", "meh")
59
+
60
+
61
+ def test_dismiss_deletes_the_item():
62
+ fake = FakeMonitorClient([""])
63
+ ReviewQueueClient(fake).dismiss("r4") # type: ignore[arg-type]
64
+ call = fake.calls[0]
65
+ assert call["method"] == "DELETE"
66
+ assert call["path"] == "/agent-monitoring/review-queue/r4"
67
+
68
+
69
+ def test_registered_on_the_monitor_client():
70
+ from agentx.monitor.client import MonitorClient
71
+
72
+ monitor = MonitorClient(api_key="k", base_url="http://engine:4700/api/v1/monitor")
73
+ assert isinstance(monitor.review_queue, ReviewQueueClient)
@@ -0,0 +1,146 @@
1
+ """Runner-level tests for dataset splits, concurrent execution, output reuse, and the
2
+ fail-fast batch submission - all against a fake EvaluationsClient, no engine required."""
3
+
4
+ import threading
5
+ import time
6
+ from typing import Any, Dict, List, Optional
7
+
8
+ import pytest
9
+
10
+ from agentx.evaluations.client import EvaluationSubmissionError
11
+ from agentx.evaluations.models import (
12
+ BatchAppendResponse,
13
+ Dataset,
14
+ EvaluationRun,
15
+ EvaluationSubject,
16
+ )
17
+ from agentx.evaluations.runner import EvaluationRunContext, _build_cases
18
+
19
+
20
+ def make_dataset(**overrides: Any) -> Dataset:
21
+ payload: Dict[str, Any] = {
22
+ "_id": "ds-1",
23
+ "name": "split dataset",
24
+ "questions": [
25
+ {"main_question": {"query": "q0", "splits": ["smoke"]}},
26
+ {"main_question": {"query": "q1"}},
27
+ {"main_question": {"query": "q2", "splits": ["smoke", "full"]}},
28
+ ],
29
+ }
30
+ payload.update(overrides)
31
+ return Dataset(**payload)
32
+
33
+
34
+ def make_run() -> EvaluationRun:
35
+ return EvaluationRun(runId="run-1", datasetId="ds-1")
36
+
37
+
38
+ class FakeClient:
39
+ def __init__(self, prior_run: Optional[Dict[str, Any]] = None, fail_batches: int = 0):
40
+ self.batches: List[List[Any]] = []
41
+ self._prior_run = prior_run
42
+ self._fail_remaining = fail_batches
43
+
44
+ def get_submitted_keys(self, run_id: str) -> List[str]:
45
+ return []
46
+
47
+ def append_results(self, run_id: str, batch_id: str, results: List[Any]) -> BatchAppendResponse:
48
+ if self._fail_remaining > 0:
49
+ self._fail_remaining -= 1
50
+ raise RuntimeError("engine down")
51
+ self.batches.append(list(results))
52
+ return BatchAppendResponse(
53
+ runId=run_id, batchId=batch_id, accepted=len(results), duplicates=0, failedValidation=0
54
+ )
55
+
56
+ def get_run(self, run_id: str) -> Dict[str, Any]:
57
+ assert self._prior_run is not None
58
+ return self._prior_run
59
+
60
+
61
+ def make_context(client: FakeClient, split: Optional[str] = None) -> EvaluationRunContext:
62
+ return EvaluationRunContext(
63
+ client, # type: ignore[arg-type]
64
+ make_dataset(),
65
+ make_run(),
66
+ EvaluationSubject(),
67
+ split=split,
68
+ )
69
+
70
+
71
+ def test_build_cases_filters_by_split_and_keeps_indexes():
72
+ cases = _build_cases(make_dataset(), make_run(), split="smoke")
73
+ assert [c.question_index for c in cases] == [0, 2]
74
+ assert [c.query for c in cases] == ["q0", "q2"]
75
+
76
+ all_cases = _build_cases(make_dataset(), make_run())
77
+ assert [c.question_index for c in all_cases] == [0, 1, 2]
78
+
79
+
80
+ def test_execute_runs_only_the_split(monkeypatch):
81
+ monkeypatch.setenv("AGENTX_EVAL_QUIET", "1")
82
+ client = FakeClient()
83
+ ctx = make_context(client, split="smoke")
84
+ seen: List[str] = []
85
+
86
+ def agent(case):
87
+ seen.append(case.query)
88
+ return f"answer to {case.query}"
89
+
90
+ ctx.execute(agent)
91
+ assert seen == ["q0", "q2"]
92
+ submitted = [r for batch in client.batches for r in batch]
93
+ assert [r.question_index for r in submitted] == [0, 2]
94
+
95
+
96
+ def test_concurrent_execution_preserves_submission_order(monkeypatch):
97
+ monkeypatch.setenv("AGENTX_EVAL_QUIET", "1")
98
+ client = FakeClient()
99
+ ctx = make_context(client)
100
+ threads: List[str] = []
101
+
102
+ def agent(case):
103
+ threads.append(threading.current_thread().name)
104
+ # The FIRST case is the slowest - order must still hold.
105
+ time.sleep(0.2 if case.query == "q0" else 0.01)
106
+ return f"answer to {case.query}"
107
+
108
+ ctx.execute(agent, concurrency=3)
109
+ submitted = [r for batch in client.batches for r in batch]
110
+ assert [r.question_index for r in submitted] == [0, 1, 2]
111
+ assert any(name != "MainThread" for name in threads)
112
+
113
+
114
+ def test_reuse_outputs_from_replays_matching_queries(monkeypatch):
115
+ monkeypatch.setenv("AGENTX_EVAL_QUIET", "1")
116
+ prior = {
117
+ "results": [
118
+ {"input": {"query": "q0"}, "output": {"text": "cached answer 0"}, "runNumber": 1},
119
+ # q1's prior row errored - must NOT be reused.
120
+ {"input": {"query": "q1"}, "output": {"text": "bad"}, "runNumber": 1, "status": "failed"},
121
+ ]
122
+ }
123
+ client = FakeClient(prior_run=prior)
124
+ ctx = make_context(client)
125
+ ran: List[str] = []
126
+
127
+ def agent(case):
128
+ ran.append(case.query)
129
+ return f"fresh answer to {case.query}"
130
+
131
+ ctx.execute(agent, reuse_outputs_from="run-0")
132
+ # q0 replayed from cache; q1 (failed before) and q2 (no cache) ran for real.
133
+ assert ran == ["q1", "q2"]
134
+ submitted = [r for batch in client.batches for r in batch]
135
+ assert submitted[0].output == {"text": "cached answer 0"}
136
+ assert submitted[0].metadata.get("reusedFromRun") == "run-0"
137
+ assert submitted[1].output == {"text": "fresh answer to q1"}
138
+
139
+
140
+ def test_flush_batch_failure_raises_after_one_retry(monkeypatch):
141
+ monkeypatch.setenv("AGENTX_EVAL_QUIET", "1")
142
+ client = FakeClient(fail_batches=2) # first attempt + its retry both fail
143
+ ctx = make_context(client)
144
+
145
+ with pytest.raises(EvaluationSubmissionError):
146
+ ctx.execute(lambda case: "x")
@@ -1 +0,0 @@
1
- VERSION = "0.8.4"
File without changes
File without changes
File without changes
File without changes