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.
- {agentx_python-0.8.4/agentx_python.egg-info → agentx_python-0.8.5}/PKG-INFO +1 -1
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/evaluations/client.py +18 -1
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/evaluations/datasets.py +8 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/evaluations/models.py +3 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/evaluations/runner.py +145 -34
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/monitor/client.py +56 -9
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/monitor/judge_scorers.py +23 -5
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/monitor/online_evaluators.py +7 -3
- agentx_python-0.8.5/agentx/monitor/review_queue.py +93 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/testing.py +3 -1
- agentx_python-0.8.5/agentx/version.py +1 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5/agentx_python.egg-info}/PKG-INFO +1 -1
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx_python.egg-info/SOURCES.txt +3 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/tests/test_integrations.py +14 -0
- agentx_python-0.8.5/tests/test_review_queue.py +73 -0
- agentx_python-0.8.5/tests/test_runner_features.py +146 -0
- agentx_python-0.8.4/agentx/version.py +0 -1
- {agentx_python-0.8.4 → agentx_python-0.8.5}/LICENSE +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/README.md +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/__init__.py +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/agentx.py +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/cli.py +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/evaluations/__init__.py +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/evaluations/_term.py +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/evaluations/adapters/__init__.py +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/evaluations/adapters/http_endpoint.py +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/evaluations/adapters/precomputed.py +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/evaluations/adapters/raw.py +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/evaluations/evaluation_settings.py +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/evaluations/prompts.py +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/evaluations/reporting.py +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/evaluations/results.py +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/evaluations/tool_schemas.py +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/evaluations/tracing.py +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/exceptions.py +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/export.py +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/feedback.py +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/integrations/__init__.py +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/integrations/_traced_call.py +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/integrations/anthropic.py +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/integrations/autogen.py +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/integrations/crewai.py +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/integrations/databricks.py +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/integrations/google_adk.py +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/integrations/google_genai.py +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/integrations/langchain.py +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/integrations/litellm.py +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/integrations/llamaindex.py +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/integrations/moveworks.py +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/integrations/openai.py +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/integrations/openai_agents.py +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/monitor/__init__.py +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/monitor/agents.py +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/monitor/models.py +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/monitor/patterns.py +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/monitor/profile.py +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/monitor/scorers.py +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/monitor/sessions.py +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/monitor/signals.py +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/outcomes.py +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/projects.py +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/py.typed +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/resources/__init__.py +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/resources/agent.py +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/resources/conversation.py +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/resources/workforce.py +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/traces.py +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/tracing/__init__.py +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/tracing/ci_types.py +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/tracing/eval_scope.py +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/tracing/ingest_client.py +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/tracing/tracer.py +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx/util.py +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx_python.egg-info/dependency_links.txt +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx_python.egg-info/entry_points.txt +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx_python.egg-info/not-zip-safe +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx_python.egg-info/requires.txt +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/agentx_python.egg-info/top_level.txt +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/setup.cfg +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/setup.py +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/tests/test_deep_dive_fixes.py +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/tests/test_docs_match_sdk.py +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/tests/test_eval_scope.py +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/tests/test_integration.py +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/tests/test_judge_scorers.py +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/tests/test_pairwise.py +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/tests/test_selfhost_analysis_fallback.py +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/tests/test_span_tree.py +0 -0
- {agentx_python-0.8.4 → agentx_python-0.8.5}/tests/test_testing.py +0 -0
|
@@ -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(
|
|
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
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
self.
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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") ->
|
|
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
|
|
237
|
-
Calibration numbers
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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(
|
|
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=
|
|
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(
|
|
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
|
-
|
|
287
|
-
|
|
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(
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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"
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|