agentic-evals 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,69 @@
1
+ """A standalone, framework-agnostic evaluation and scoring engine.
2
+
3
+ Score whatever trace-shaped data you give it (see `EvalTrace`/`EvalSpan`) —
4
+ this package has no dependency on any specific tracing/observability tool.
5
+ Extracted from AgenticLens's evaluation module (github.com/DeepAgentLabs/agenticlens).
6
+ """
7
+
8
+ from agentic_evals.evaluators import (
9
+ BusinessRuleEvaluator,
10
+ CallableEvaluator,
11
+ EvaluationContext,
12
+ Evaluator,
13
+ EvaluatorRegistry,
14
+ LLMJudgeEvaluator,
15
+ )
16
+ from agentic_evals.gate import GateConfig, GateDecision, evaluate_gate
17
+ from agentic_evals.models import (
18
+ CaseEvaluation,
19
+ EvalSpan,
20
+ EvalTrace,
21
+ EvaluationReport,
22
+ EvaluationSample,
23
+ EvaluationSummary,
24
+ EvaluatorConfig,
25
+ HTTPTarget,
26
+ LiveTarget,
27
+ PythonTarget,
28
+ Score,
29
+ TestCase,
30
+ TestSuite,
31
+ )
32
+ from agentic_evals.runner import (
33
+ evaluate_suite,
34
+ load_samples,
35
+ load_suite,
36
+ run_live_suite,
37
+ )
38
+
39
+ __version__ = "0.1.0"
40
+
41
+ __all__ = [
42
+ "BusinessRuleEvaluator",
43
+ "CallableEvaluator",
44
+ "CaseEvaluation",
45
+ "EvalSpan",
46
+ "EvalTrace",
47
+ "EvaluationContext",
48
+ "EvaluationReport",
49
+ "EvaluationSample",
50
+ "EvaluationSummary",
51
+ "Evaluator",
52
+ "EvaluatorConfig",
53
+ "EvaluatorRegistry",
54
+ "GateConfig",
55
+ "GateDecision",
56
+ "HTTPTarget",
57
+ "LLMJudgeEvaluator",
58
+ "LiveTarget",
59
+ "PythonTarget",
60
+ "Score",
61
+ "TestCase",
62
+ "TestSuite",
63
+ "__version__",
64
+ "evaluate_gate",
65
+ "evaluate_suite",
66
+ "load_samples",
67
+ "load_suite",
68
+ "run_live_suite",
69
+ ]
@@ -0,0 +1,95 @@
1
+ from collections.abc import Callable
2
+ from dataclasses import dataclass
3
+ from typing import Protocol
4
+
5
+ from agentic_evals.models import (
6
+ EvaluationSample,
7
+ EvaluatorConfig,
8
+ Score,
9
+ TestCase,
10
+ )
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class EvaluationContext:
15
+ case: TestCase
16
+ sample: EvaluationSample
17
+ config: EvaluatorConfig
18
+
19
+
20
+ class Evaluator(Protocol):
21
+ @property
22
+ def name(self) -> str: ...
23
+
24
+ def evaluate(self, context: EvaluationContext) -> list[Score]: ...
25
+
26
+
27
+ EvaluationFunction = Callable[[EvaluationContext], Score | list[Score]]
28
+
29
+
30
+ class CallableEvaluator:
31
+ """Adapt a trusted Python function to the unified evaluator contract."""
32
+
33
+ def __init__(
34
+ self,
35
+ name: str,
36
+ function: EvaluationFunction,
37
+ *,
38
+ evaluator_type: str = "custom",
39
+ ) -> None:
40
+ if not name:
41
+ raise ValueError("evaluator name must not be empty")
42
+ self._name = name
43
+ self._function = function
44
+ self._evaluator_type = evaluator_type
45
+
46
+ @property
47
+ def name(self) -> str:
48
+ return self._name
49
+
50
+ def evaluate(self, context: EvaluationContext) -> list[Score]:
51
+ result = self._function(context)
52
+ scores = result if isinstance(result, list) else [result]
53
+ return [
54
+ score.model_copy(
55
+ update={
56
+ "evaluator_type": self._evaluator_type,
57
+ "passed": score.value >= context.config.threshold,
58
+ "required": context.config.required,
59
+ }
60
+ )
61
+ for score in scores
62
+ ]
63
+
64
+
65
+ class LLMJudgeEvaluator(CallableEvaluator):
66
+ """Provider-neutral adapter for an application-supplied LLM judge function."""
67
+
68
+ def __init__(self, name: str, judge: EvaluationFunction) -> None:
69
+ super().__init__(name, judge, evaluator_type="llm_judge")
70
+
71
+
72
+ class BusinessRuleEvaluator(CallableEvaluator):
73
+ """Named helper for application-specific pass/fail logic."""
74
+
75
+ def __init__(self, name: str, rule: EvaluationFunction) -> None:
76
+ super().__init__(name, rule, evaluator_type="business_rule")
77
+
78
+
79
+ class EvaluatorRegistry:
80
+ def __init__(self) -> None:
81
+ self._evaluators: dict[str, Evaluator] = {}
82
+
83
+ def register(self, evaluator: Evaluator, *, replace: bool = False) -> None:
84
+ if evaluator.name in self._evaluators and not replace:
85
+ raise ValueError(f"evaluator {evaluator.name!r} is already registered")
86
+ self._evaluators[evaluator.name] = evaluator
87
+
88
+ def get(self, name: str) -> Evaluator:
89
+ try:
90
+ return self._evaluators[name]
91
+ except KeyError as exc:
92
+ raise ValueError(f"evaluator {name!r} is not registered") from exc
93
+
94
+ def names(self) -> tuple[str, ...]:
95
+ return tuple(sorted(self._evaluators))
agentic_evals/gate.py ADDED
@@ -0,0 +1,61 @@
1
+ from pydantic import BaseModel, Field
2
+
3
+ from agentic_evals.models import EvaluationReport
4
+
5
+
6
+ class GateConfig(BaseModel):
7
+ min_pass_rate: float = Field(default=1.0, ge=0, le=1)
8
+ min_average_score: float = Field(default=1.0, ge=0, le=1)
9
+ max_failed_cases: int = Field(default=0, ge=0)
10
+ max_average_latency_ms: float | None = Field(default=None, gt=0)
11
+ max_total_cost_usd: float | None = Field(default=None, ge=0)
12
+
13
+
14
+ class GateDecision(BaseModel):
15
+ passed: bool
16
+ reasons: list[str]
17
+ observed: dict[str, float | int | None]
18
+
19
+
20
+ def evaluate_gate(report: EvaluationReport, config: GateConfig) -> GateDecision:
21
+ summary = report.summary
22
+ reasons: list[str] = []
23
+ if summary.pass_rate < config.min_pass_rate:
24
+ reasons.append(f"Pass rate {summary.pass_rate:.1%} is below {config.min_pass_rate:.1%}.")
25
+ if summary.average_score < config.min_average_score:
26
+ reasons.append(
27
+ f"Average score {summary.average_score:.3f} is below {config.min_average_score:.3f}."
28
+ )
29
+ if summary.failed_cases > config.max_failed_cases:
30
+ reasons.append(f"Failed cases {summary.failed_cases} exceed {config.max_failed_cases}.")
31
+ if (
32
+ config.max_average_latency_ms is not None
33
+ and summary.average_latency_ms > config.max_average_latency_ms
34
+ ):
35
+ reasons.append(
36
+ f"Average latency {summary.average_latency_ms:.1f} ms exceeds "
37
+ f"{config.max_average_latency_ms:.1f} ms."
38
+ )
39
+ if config.max_total_cost_usd is not None:
40
+ if (
41
+ summary.total_cost_usd is None
42
+ or len(report.cases) != summary.total_cases
43
+ or any(case.cost_usd is None for case in report.cases)
44
+ ):
45
+ reasons.append("Total cost is unavailable or incomplete.")
46
+ elif summary.total_cost_usd > config.max_total_cost_usd:
47
+ reasons.append(
48
+ f"Total cost ${summary.total_cost_usd:.6f} exceeds "
49
+ f"${config.max_total_cost_usd:.6f}."
50
+ )
51
+ return GateDecision(
52
+ passed=not reasons,
53
+ reasons=reasons,
54
+ observed={
55
+ "pass_rate": summary.pass_rate,
56
+ "average_score": summary.average_score,
57
+ "failed_cases": summary.failed_cases,
58
+ "average_latency_ms": summary.average_latency_ms,
59
+ "total_cost_usd": summary.total_cost_usd,
60
+ },
61
+ )
@@ -0,0 +1,154 @@
1
+ from datetime import datetime, timezone
2
+ from typing import Any
3
+
4
+ from pydantic import BaseModel, Field, model_validator
5
+
6
+
7
+ class EvalSpan(BaseModel):
8
+ """The minimal per-span shape the evaluation engine actually inspects.
9
+
10
+ Deliberately not AgenticLens's `Span` — a much richer model with agent
11
+ names, model/provider, retries, timestamps, and more. This package
12
+ never needs any of that, so it doesn't ask for it. Anyone with any
13
+ trace-shaped data can produce an `EvalTrace` without depending on a
14
+ specific tracing tool's schema.
15
+ """
16
+
17
+ tool_name: str | None = None
18
+ attributes: dict[str, Any] = Field(default_factory=dict)
19
+
20
+
21
+ class EvalTrace(BaseModel):
22
+ """The minimal per-run shape the evaluation engine actually inspects."""
23
+
24
+ trace_id: str = ""
25
+ spans: list[EvalSpan] = Field(default_factory=list)
26
+ total_latency_ms: float = 0.0
27
+ estimated_cost_usd: float | None = None
28
+ metadata: dict[str, Any] = Field(default_factory=dict)
29
+
30
+
31
+ class EvaluatorConfig(BaseModel):
32
+ name: str = Field(min_length=1)
33
+ threshold: float = Field(default=1.0, ge=0, le=1)
34
+ required: bool = True
35
+ config: dict[str, Any] = Field(default_factory=dict)
36
+
37
+
38
+ class TestCase(BaseModel):
39
+ id: str
40
+ name: str
41
+ input: Any = None
42
+ expected_output: str | None = None
43
+ expected_contains: list[str] = Field(default_factory=list)
44
+ output_json_schema: dict[str, Any] | None = None
45
+ required_output_fields: list[str] = Field(default_factory=list)
46
+ required_tools: list[str] = Field(default_factory=list)
47
+ forbidden_tools: list[str] = Field(default_factory=list)
48
+ required_tool_arguments: dict[str, list[str]] = Field(default_factory=dict)
49
+ max_latency_ms: float | None = Field(default=None, gt=0)
50
+ max_cost_usd: float | None = Field(default=None, ge=0)
51
+ max_turns: int | None = Field(default=None, gt=0)
52
+ evaluators: list[EvaluatorConfig] = Field(default_factory=list)
53
+ tags: list[str] = Field(default_factory=list)
54
+ metadata: dict[str, Any] = Field(default_factory=dict)
55
+
56
+ @model_validator(mode="after")
57
+ def require_expectation(self) -> "TestCase":
58
+ if not any(
59
+ (
60
+ self.expected_output is not None,
61
+ self.expected_contains,
62
+ self.output_json_schema is not None,
63
+ self.required_output_fields,
64
+ self.required_tools,
65
+ self.forbidden_tools,
66
+ self.required_tool_arguments,
67
+ self.max_latency_ms is not None,
68
+ self.max_cost_usd is not None,
69
+ self.max_turns is not None,
70
+ self.evaluators,
71
+ )
72
+ ):
73
+ raise ValueError("test case must define at least one expectation")
74
+ return self
75
+
76
+
77
+ class TestSuite(BaseModel):
78
+ name: str
79
+ version: str
80
+ description: str = ""
81
+ cases: list[TestCase]
82
+ metadata: dict[str, Any] = Field(default_factory=dict)
83
+
84
+ @model_validator(mode="after")
85
+ def unique_case_ids(self) -> "TestSuite":
86
+ ids = [case.id for case in self.cases]
87
+ if len(ids) != len(set(ids)):
88
+ raise ValueError("test case IDs must be unique")
89
+ if not ids:
90
+ raise ValueError("test suite must contain at least one case")
91
+ return self
92
+
93
+
94
+ class EvaluationSample(BaseModel):
95
+ case_id: str
96
+ output: str
97
+ trace: EvalTrace
98
+
99
+
100
+ class Score(BaseModel):
101
+ name: str
102
+ value: float = Field(ge=0, le=1)
103
+ passed: bool
104
+ required: bool = True
105
+ explanation: str
106
+ evaluator_type: str = "deterministic"
107
+ metadata: dict[str, Any] = Field(default_factory=dict)
108
+
109
+
110
+ class CaseEvaluation(BaseModel):
111
+ case_id: str
112
+ case_name: str
113
+ passed: bool
114
+ scores: list[Score]
115
+ output: str
116
+ trace_id: str
117
+ latency_ms: float
118
+ cost_usd: float | None = None
119
+
120
+
121
+ class EvaluationSummary(BaseModel):
122
+ total_cases: int
123
+ passed_cases: int
124
+ failed_cases: int
125
+ pass_rate: float = Field(ge=0, le=1)
126
+ average_score: float = Field(ge=0, le=1)
127
+ total_cost_usd: float | None = None
128
+ average_latency_ms: float
129
+
130
+
131
+ class EvaluationReport(BaseModel):
132
+ schema_version: str = "1.0"
133
+ suite_name: str
134
+ suite_version: str
135
+ created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
136
+ summary: EvaluationSummary
137
+ cases: list[CaseEvaluation]
138
+
139
+
140
+ class LiveTarget(BaseModel):
141
+ kind: str = Field(pattern="^(python|http)$")
142
+ timeout_seconds: float = Field(default=30.0, gt=0)
143
+
144
+
145
+ class PythonTarget(LiveTarget):
146
+ kind: str = "python"
147
+ callable_path: str
148
+
149
+
150
+ class HTTPTarget(LiveTarget):
151
+ kind: str = "http"
152
+ url: str
153
+ method: str = "POST"
154
+ headers: dict[str, str] = Field(default_factory=dict)
agentic_evals/py.typed ADDED
File without changes
@@ -0,0 +1,409 @@
1
+ import json
2
+ import urllib.error
3
+ import urllib.request
4
+ from importlib import import_module
5
+ from importlib.util import module_from_spec, spec_from_file_location
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import yaml
10
+ from jsonschema import Draft202012Validator, SchemaError
11
+ from referencing import Registry
12
+ from referencing.exceptions import Unresolvable
13
+
14
+ from agentic_evals.evaluators import EvaluationContext, EvaluatorRegistry
15
+ from agentic_evals.models import (
16
+ CaseEvaluation,
17
+ EvaluationReport,
18
+ EvaluationSample,
19
+ EvaluationSummary,
20
+ HTTPTarget,
21
+ LiveTarget,
22
+ PythonTarget,
23
+ Score,
24
+ TestCase,
25
+ TestSuite,
26
+ )
27
+
28
+
29
+ def _load_data(path: Path) -> Any:
30
+ text = path.read_text(encoding="utf-8")
31
+ return json.loads(text) if path.suffix.lower() == ".json" else yaml.safe_load(text)
32
+
33
+
34
+ def load_suite(path: Path) -> TestSuite:
35
+ return TestSuite.model_validate(_load_data(path))
36
+
37
+
38
+ def load_samples(path: Path) -> list[EvaluationSample]:
39
+ data = _load_data(path)
40
+ items = data["samples"] if isinstance(data, dict) and "samples" in data else data
41
+ return [EvaluationSample.model_validate(item) for item in items]
42
+
43
+
44
+ def _lookup_path(payload: Any, dotted_path: str) -> tuple[bool, Any]:
45
+ current = payload
46
+ for part in dotted_path.split("."):
47
+ if isinstance(current, dict) and part in current:
48
+ current = current[part]
49
+ elif isinstance(current, list) and part.isdigit() and int(part) < len(current):
50
+ current = current[int(part)]
51
+ else:
52
+ return False, None
53
+ return True, current
54
+
55
+
56
+ def _schema_validator(schema: dict[str, Any]) -> Any:
57
+ dialect = schema.get("$schema", "https://json-schema.org/draft/2020-12/schema")
58
+ if dialect != "https://json-schema.org/draft/2020-12/schema":
59
+ raise ValueError("output_json_schema supports JSON Schema Draft 2020-12 only")
60
+ try:
61
+ Draft202012Validator.check_schema(schema)
62
+ except SchemaError as exc:
63
+ raise ValueError(f"Invalid output_json_schema: {exc.message}") from exc
64
+ # Resolve embedded references only; never fetch schemas from the network.
65
+ return Draft202012Validator(schema, registry=Registry())
66
+
67
+
68
+ def _validate_json_schema(payload: Any, schema: dict[str, Any]) -> tuple[bool, str]:
69
+ validator = _schema_validator(schema)
70
+ try:
71
+ error = next(validator.iter_errors(payload), None)
72
+ except Unresolvable as exc:
73
+ raise ValueError(f"Unresolvable output_json_schema reference: {exc}") from exc
74
+ if error is not None:
75
+ return False, f"Output violates JSON Schema at {error.json_path}: {error.message}"
76
+ return True, "Output matches the configured JSON Schema Draft 2020-12."
77
+
78
+
79
+ def _reject_json_constant(value: str) -> Any:
80
+ raise ValueError(f"Non-finite JSON constant {value} is not allowed")
81
+
82
+
83
+ def _turn_count(sample: EvaluationSample) -> int | None:
84
+ metadata_turns = sample.trace.metadata.get("turn_count")
85
+ if isinstance(metadata_turns, int) and metadata_turns > 0:
86
+ return metadata_turns
87
+ return None
88
+
89
+
90
+ def _score_case(
91
+ case: TestCase,
92
+ sample: EvaluationSample,
93
+ registry: EvaluatorRegistry | None,
94
+ ) -> list[Score]:
95
+ scores: list[Score] = []
96
+ output = sample.output.strip()
97
+ if case.expected_output is not None:
98
+ passed = output == case.expected_output.strip()
99
+ scores.append(
100
+ Score(
101
+ name="exact_match",
102
+ value=float(passed),
103
+ passed=passed,
104
+ explanation="Output exactly matches the reference."
105
+ if passed
106
+ else "Output does not exactly match the reference.",
107
+ )
108
+ )
109
+ for expected in case.expected_contains:
110
+ passed = expected.casefold() in output.casefold()
111
+ scores.append(
112
+ Score(
113
+ name=f"contains:{expected}",
114
+ value=float(passed),
115
+ passed=passed,
116
+ explanation=f"Output contains required text: {expected!r}."
117
+ if passed
118
+ else f"Output is missing required text: {expected!r}.",
119
+ )
120
+ )
121
+ parsed_output: Any | None = None
122
+ output_json_error: str | None = None
123
+ if case.output_json_schema is not None or case.required_output_fields:
124
+ try:
125
+ parsed_output = json.loads(output, parse_constant=_reject_json_constant)
126
+ except ValueError as exc:
127
+ output_json_error = str(exc)
128
+ if case.output_json_schema is not None:
129
+ if output_json_error is not None:
130
+ scores.append(
131
+ Score(
132
+ name="json_schema",
133
+ value=0.0,
134
+ passed=False,
135
+ explanation=f"Output is not valid JSON: {output_json_error}.",
136
+ )
137
+ )
138
+ else:
139
+ valid, reason = _validate_json_schema(parsed_output, case.output_json_schema)
140
+ scores.append(
141
+ Score(
142
+ name="json_schema",
143
+ value=float(valid),
144
+ passed=valid,
145
+ explanation=reason,
146
+ )
147
+ )
148
+ for field_path in case.required_output_fields:
149
+ exists = (
150
+ output_json_error is None
151
+ and parsed_output is not None
152
+ and _lookup_path(parsed_output, field_path)[0]
153
+ )
154
+ scores.append(
155
+ Score(
156
+ name=f"required_field:{field_path}",
157
+ value=float(exists),
158
+ passed=exists,
159
+ explanation=f"Output contains required field {field_path!r}."
160
+ if exists
161
+ else (
162
+ "Output is not valid JSON, so required field "
163
+ f"{field_path!r} could not be checked."
164
+ if output_json_error is not None
165
+ else f"Output is missing required field {field_path!r}."
166
+ ),
167
+ )
168
+ )
169
+
170
+ tools = {span.tool_name for span in sample.trace.spans if span.tool_name}
171
+ for tool in case.required_tools:
172
+ passed = tool in tools
173
+ scores.append(
174
+ Score(
175
+ name=f"required_tool:{tool}",
176
+ value=float(passed),
177
+ passed=passed,
178
+ explanation=f"Required tool {tool!r} was called."
179
+ if passed
180
+ else f"Required tool {tool!r} was not called.",
181
+ )
182
+ )
183
+ for tool in case.forbidden_tools:
184
+ passed = tool not in tools
185
+ scores.append(
186
+ Score(
187
+ name=f"forbidden_tool:{tool}",
188
+ value=float(passed),
189
+ passed=passed,
190
+ explanation=f"Forbidden tool {tool!r} was not called."
191
+ if passed
192
+ else f"Forbidden tool {tool!r} was called.",
193
+ )
194
+ )
195
+ for tool_name, required_args in case.required_tool_arguments.items():
196
+ matching_spans = [span for span in sample.trace.spans if span.tool_name == tool_name]
197
+ args_present = False
198
+ for span in matching_spans:
199
+ tool_args = span.attributes.get("tool_args")
200
+ if isinstance(tool_args, dict) and all(arg in tool_args for arg in required_args):
201
+ args_present = True
202
+ break
203
+ scores.append(
204
+ Score(
205
+ name=f"tool_args:{tool_name}",
206
+ value=float(args_present),
207
+ passed=args_present,
208
+ explanation=f"Tool {tool_name!r} included required arguments {required_args}."
209
+ if args_present
210
+ else f"Tool {tool_name!r} did not include required arguments {required_args}.",
211
+ )
212
+ )
213
+ if case.max_latency_ms is not None:
214
+ passed = sample.trace.total_latency_ms <= case.max_latency_ms
215
+ scores.append(
216
+ Score(
217
+ name="latency_threshold",
218
+ value=float(passed),
219
+ passed=passed,
220
+ explanation=(
221
+ f"Latency {sample.trace.total_latency_ms:.1f} ms "
222
+ f"{'meets' if passed else 'exceeds'} the "
223
+ f"{case.max_latency_ms:.1f} ms limit."
224
+ ),
225
+ )
226
+ )
227
+ if case.max_cost_usd is not None:
228
+ cost = sample.trace.estimated_cost_usd
229
+ passed = cost is not None and cost <= case.max_cost_usd
230
+ scores.append(
231
+ Score(
232
+ name="cost_threshold",
233
+ value=float(passed),
234
+ passed=passed,
235
+ explanation=(
236
+ f"Cost ${cost:.6f} meets the ${case.max_cost_usd:.6f} limit."
237
+ if passed and cost is not None
238
+ else "Cost is unavailable or exceeds the configured limit."
239
+ ),
240
+ )
241
+ )
242
+ if case.max_turns is not None:
243
+ turns = _turn_count(sample)
244
+ passed = turns is not None and turns <= case.max_turns
245
+ scores.append(
246
+ Score(
247
+ name="turn_count_threshold",
248
+ value=float(passed),
249
+ passed=passed,
250
+ explanation=(
251
+ f"Turn count {turns} {'meets' if passed else 'exceeds'} the "
252
+ f"{case.max_turns} turn limit."
253
+ if turns is not None
254
+ else (
255
+ "Trace metadata is missing a positive integer turn_count, "
256
+ "so the max_turns check could not be evaluated."
257
+ )
258
+ ),
259
+ )
260
+ )
261
+ for evaluator_config in case.evaluators:
262
+ if registry is None:
263
+ raise ValueError(
264
+ f"test case {case.id!r} requires evaluator {evaluator_config.name!r}, "
265
+ "but no evaluator registry was supplied"
266
+ )
267
+ evaluator = registry.get(evaluator_config.name)
268
+ custom_scores = evaluator.evaluate(
269
+ EvaluationContext(case=case, sample=sample, config=evaluator_config)
270
+ )
271
+ scores.extend(custom_scores)
272
+ return scores
273
+
274
+
275
+ def _load_python_callable(callable_path: str) -> Any:
276
+ """Load a trusted Python live target from module:function or path.py:function."""
277
+ module_name, _, attr_path = callable_path.rpartition(":")
278
+ if not module_name or not attr_path:
279
+ raise ValueError("python target callable_path must be in module:function format")
280
+ try:
281
+ target: Any = import_module(module_name)
282
+ except ModuleNotFoundError as exc:
283
+ file_path = Path(module_name)
284
+ if not file_path.exists():
285
+ raise
286
+ spec = spec_from_file_location(file_path.stem, file_path)
287
+ if spec is None or spec.loader is None:
288
+ raise ValueError(f"Unable to load Python target from {file_path}") from exc
289
+ module = module_from_spec(spec)
290
+ spec.loader.exec_module(module)
291
+ target = module
292
+ for part in attr_path.split("."):
293
+ target = getattr(target, part)
294
+ return target
295
+
296
+
297
+ def run_live_suite(
298
+ suite: TestSuite,
299
+ target: LiveTarget,
300
+ *,
301
+ registry: EvaluatorRegistry | None = None,
302
+ ) -> EvaluationReport:
303
+ """Run a trusted live target against every case in a suite.
304
+
305
+ Live targets are intentionally powerful developer-facing integrations:
306
+ Python targets execute local code and HTTP targets can reach arbitrary URLs.
307
+ Only use trusted suite files and trusted target definitions.
308
+ """
309
+ samples: list[EvaluationSample] = []
310
+ if isinstance(target, PythonTarget):
311
+ callable_target = _load_python_callable(target.callable_path)
312
+ for case in suite.cases:
313
+ result = callable_target(case.input, case=case)
314
+ sample = EvaluationSample.model_validate({**result, "case_id": case.id})
315
+ samples.append(sample)
316
+ elif isinstance(target, HTTPTarget):
317
+ for case in suite.cases:
318
+ request = urllib.request.Request(
319
+ target.url,
320
+ data=json.dumps({"input": case.input, "case_id": case.id}).encode("utf-8"),
321
+ headers={"Content-Type": "application/json", **target.headers},
322
+ method=target.method.upper(),
323
+ )
324
+ try:
325
+ with urllib.request.urlopen(request, timeout=target.timeout_seconds) as response:
326
+ payload = json.loads(response.read().decode("utf-8"))
327
+ except urllib.error.URLError as exc:
328
+ raise ValueError(f"HTTP target request failed for case {case.id!r}: {exc}") from exc
329
+ sample = EvaluationSample.model_validate({**payload, "case_id": case.id})
330
+ samples.append(sample)
331
+ else:
332
+ raise ValueError(f"Unsupported live target kind: {target.kind}")
333
+ return evaluate_suite(suite, samples, registry=registry)
334
+
335
+
336
+ def evaluate_suite(
337
+ suite: TestSuite,
338
+ samples: list[EvaluationSample],
339
+ *,
340
+ registry: EvaluatorRegistry | None = None,
341
+ ) -> EvaluationReport:
342
+ case_ids = [case.id for case in suite.cases]
343
+ if not case_ids or len(case_ids) != len(set(case_ids)):
344
+ raise ValueError("suite must contain nonempty, unique case IDs")
345
+ for case in suite.cases:
346
+ if case.output_json_schema is not None:
347
+ _schema_validator(case.output_json_schema)
348
+ by_case: dict[str, EvaluationSample] = {}
349
+ expected = set(case_ids)
350
+ for supplied in samples:
351
+ if supplied.case_id in by_case:
352
+ raise ValueError(f"Duplicate sample case ID: {supplied.case_id!r}")
353
+ if supplied.case_id not in expected:
354
+ raise ValueError(f"Unknown sample case ID: {supplied.case_id!r}")
355
+ by_case[supplied.case_id] = supplied
356
+ results: list[CaseEvaluation] = []
357
+ for case in suite.cases:
358
+ sample = by_case.get(case.id)
359
+ if sample is None:
360
+ scores = [
361
+ Score(
362
+ name="sample_available",
363
+ value=0,
364
+ passed=False,
365
+ explanation="No evaluation sample was supplied for this test case.",
366
+ )
367
+ ]
368
+ results.append(
369
+ CaseEvaluation(
370
+ case_id=case.id,
371
+ case_name=case.name,
372
+ passed=False,
373
+ scores=scores,
374
+ output="",
375
+ trace_id="",
376
+ latency_ms=0,
377
+ )
378
+ )
379
+ continue
380
+ scores = _score_case(case, sample, registry)
381
+ results.append(
382
+ CaseEvaluation(
383
+ case_id=case.id,
384
+ case_name=case.name,
385
+ passed=all(score.passed or not score.required for score in scores),
386
+ scores=scores,
387
+ output=sample.output,
388
+ trace_id=sample.trace.trace_id,
389
+ latency_ms=sample.trace.total_latency_ms,
390
+ cost_usd=sample.trace.estimated_cost_usd,
391
+ )
392
+ )
393
+ passed = sum(result.passed for result in results)
394
+ all_scores = [score.value for result in results for score in result.scores]
395
+ costs = [result.cost_usd for result in results if result.cost_usd is not None]
396
+ return EvaluationReport(
397
+ suite_name=suite.name,
398
+ suite_version=suite.version,
399
+ summary=EvaluationSummary(
400
+ total_cases=len(results),
401
+ passed_cases=passed,
402
+ failed_cases=len(results) - passed,
403
+ pass_rate=passed / len(results),
404
+ average_score=sum(all_scores) / len(all_scores),
405
+ total_cost_usd=sum(costs) if len(costs) == len(results) else None,
406
+ average_latency_ms=sum(result.latency_ms for result in results) / len(results),
407
+ ),
408
+ cases=results,
409
+ )
@@ -0,0 +1,237 @@
1
+ Metadata-Version: 2.5
2
+ Name: agentic-evals
3
+ Version: 0.1.0
4
+ Summary: A standalone, framework-agnostic evaluation and scoring engine for LLM/agent outputs — extracted from AgenticLens's evaluation module.
5
+ Project-URL: Homepage, https://github.com/DeepAgentLabs/agentic-evals
6
+ Project-URL: Repository, https://github.com/DeepAgentLabs/agentic-evals
7
+ Project-URL: Issues, https://github.com/DeepAgentLabs/agentic-evals/issues
8
+ Project-URL: Changelog, https://github.com/DeepAgentLabs/agentic-evals/blob/main/CHANGELOG.md
9
+ Author: agentic-evals Contributors
10
+ License: MIT License
11
+
12
+ Copyright (c) 2026 DeepAgentLabs
13
+
14
+ Permission is hereby granted, free of charge, to any person obtaining a copy
15
+ of this software and associated documentation files (the "Software"), to deal
16
+ in the Software without restriction, including without limitation the rights
17
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
18
+ copies of the Software, and to permit persons to whom the Software is
19
+ furnished to do so, subject to the following conditions:
20
+
21
+ The above copyright notice and this permission notice shall be included in all
22
+ copies or substantial portions of the Software.
23
+
24
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
25
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
26
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
27
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
28
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
29
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
30
+ SOFTWARE.
31
+ License-File: LICENSE
32
+ Keywords: agents,evaluation,llm,llm-as-judge,scoring,testing
33
+ Classifier: Development Status :: 3 - Alpha
34
+ Classifier: Intended Audience :: Developers
35
+ Classifier: License :: OSI Approved :: MIT License
36
+ Classifier: Programming Language :: Python :: 3.10
37
+ Classifier: Programming Language :: Python :: 3.11
38
+ Classifier: Programming Language :: Python :: 3.12
39
+ Classifier: Programming Language :: Python :: 3.13
40
+ Classifier: Programming Language :: Python :: 3.14
41
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
42
+ Requires-Python: >=3.10
43
+ Requires-Dist: jsonschema>=4.23
44
+ Requires-Dist: pydantic<3,>=2.0
45
+ Requires-Dist: pyyaml<7,>=6.0
46
+ Requires-Dist: referencing>=0.35
47
+ Provides-Extra: dev
48
+ Requires-Dist: build>=1.2; extra == 'dev'
49
+ Requires-Dist: mypy>=1.10; extra == 'dev'
50
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
51
+ Requires-Dist: pytest>=8.0; extra == 'dev'
52
+ Requires-Dist: ruff>=0.6; extra == 'dev'
53
+ Requires-Dist: twine>=5.1; extra == 'dev'
54
+ Requires-Dist: types-pyyaml>=6.0; extra == 'dev'
55
+ Description-Content-Type: text/markdown
56
+
57
+ # agentic-evals
58
+
59
+ **A standalone, framework-agnostic evaluation and scoring engine for LLM and
60
+ agent outputs.**
61
+
62
+ Extracted from [AgenticLens](https://github.com/DeepAgentLabs/agenticlens)'s
63
+ proven evaluation module — the same engine, usable on its own. It scores
64
+ whatever trace-shaped data you give it (see `EvalTrace`/`EvalSpan` below);
65
+ it has no dependency on any specific tracing/observability tool, and no
66
+ dependency on AgenticLens itself.
67
+
68
+ ## Status
69
+
70
+ **Alpha.** The engine (deterministic checks, LLM-as-judge and custom
71
+ evaluators, a release gate, live Python/HTTP targets) is real, tested code
72
+ lifted directly from AgenticLens's evaluation module. No PyPI release yet.
73
+
74
+ ## Why a separate package
75
+
76
+ AgenticLens's evaluation module was never actually AgenticLens-specific —
77
+ scoring an LLM/agent output against expectations doesn't need AgenticLens's
78
+ full trace schema, CLI, or dashboards. Pulling it out means:
79
+
80
+ - `agentic-sidecar`, `agentic-chaos`, or any other project can score outputs
81
+ without depending on all of AgenticLens.
82
+ - Anyone with *any* trace-shaped data — not just AgenticLens users — can use
83
+ it, the same way Braintrust's `autoevals` doesn't care what produced the
84
+ string it's scoring.
85
+
86
+ ## Install
87
+
88
+ ```bash
89
+ pip install agentic-evals
90
+ ```
91
+
92
+ ## Core concepts
93
+
94
+ - **`EvalTrace`/`EvalSpan`** — the minimal trace shape the engine inspects:
95
+ a trace id, spans (each optionally naming a `tool_name` and carrying
96
+ arbitrary `attributes`), total latency, estimated cost, and metadata.
97
+ Deliberately not tied to any specific instrumentation format — build one
98
+ from whatever you already have.
99
+ - **`Score`** — a single named judgment (0-1 value, pass/fail, explanation).
100
+ - **`Evaluator`** — anything with a `.name` and an `.evaluate(context) ->
101
+ list[Score]`. `CallableEvaluator` adapts a plain Python function;
102
+ `LLMJudgeEvaluator` and `BusinessRuleEvaluator` are named convenience
103
+ subclasses for readability/reporting.
104
+ - **`TestCase`/`TestSuite`** — declarative expectations (exact match,
105
+ substring, JSON Schema, required fields, required/forbidden tool calls,
106
+ required tool arguments, latency/cost/turn-count thresholds, or a named
107
+ custom evaluator) plus the cases that make up a suite.
108
+ - **`evaluate_suite`** — runs a suite against supplied `EvaluationSample`s
109
+ and returns an `EvaluationReport` (per-case scores plus a pass-rate/cost/
110
+ latency summary).
111
+ - **`GateConfig`/`evaluate_gate`** — turn an `EvaluationReport` into a
112
+ pass/fail release decision on configurable thresholds.
113
+
114
+ ## Quickstart
115
+
116
+ ```python
117
+ from agentic_evals import (
118
+ EvalSpan,
119
+ EvalTrace,
120
+ EvaluationSample,
121
+ TestCase,
122
+ TestSuite,
123
+ evaluate_suite,
124
+ )
125
+
126
+ suite = TestSuite(
127
+ name="support-answers",
128
+ version="1",
129
+ cases=[
130
+ TestCase(
131
+ id="case-1",
132
+ name="Answer contains the right total",
133
+ expected_contains=["42"],
134
+ required_tools=["calculator"],
135
+ max_latency_ms=2000,
136
+ )
137
+ ],
138
+ )
139
+
140
+ sample = EvaluationSample(
141
+ case_id="case-1",
142
+ output="The combined total is 42.",
143
+ trace=EvalTrace(
144
+ trace_id="trace-1",
145
+ total_latency_ms=350,
146
+ spans=[EvalSpan(tool_name="calculator")],
147
+ ),
148
+ )
149
+
150
+ report = evaluate_suite(suite, [sample])
151
+ print(report.summary.pass_rate) # 1.0
152
+ ```
153
+
154
+ ## LLM-as-judge
155
+
156
+ ```python
157
+ from agentic_evals import (
158
+ EvaluationContext,
159
+ EvaluatorConfig,
160
+ EvaluatorRegistry,
161
+ LLMJudgeEvaluator,
162
+ Score,
163
+ TestCase,
164
+ )
165
+
166
+
167
+ def judge(context: EvaluationContext) -> Score:
168
+ # Call whatever model/provider you like here.
169
+ correct = "42" in context.sample.output
170
+ return Score(
171
+ name="answer_quality",
172
+ value=0.95 if correct else 0.1,
173
+ passed=correct,
174
+ explanation="Judged against the rubric in context.config.config.",
175
+ )
176
+
177
+
178
+ registry = EvaluatorRegistry()
179
+ registry.register(LLMJudgeEvaluator("answer_quality_judge", judge))
180
+
181
+ case = TestCase(
182
+ id="case-1",
183
+ name="Answer quality",
184
+ evaluators=[EvaluatorConfig(name="answer_quality_judge", threshold=0.8)],
185
+ )
186
+ ```
187
+
188
+ ## Release gates
189
+
190
+ ```python
191
+ from agentic_evals import GateConfig, evaluate_gate
192
+
193
+ decision = evaluate_gate(
194
+ report,
195
+ GateConfig(min_pass_rate=0.95, max_average_latency_ms=1500, max_total_cost_usd=0.25),
196
+ )
197
+ if not decision.passed:
198
+ raise SystemExit(f"Release gate failed: {decision.reasons}")
199
+ ```
200
+
201
+ Never fabricates a value it can't back up: `total_cost_usd` on a summary or
202
+ gate decision stays `None` unless every case in scope has a known cost —
203
+ an incomplete cost picture is reported as unavailable, not `$0.00`.
204
+
205
+ ## Live targets
206
+
207
+ Point a suite at a real running system (a trusted Python callable, or an
208
+ HTTP endpoint) instead of pre-recorded samples:
209
+
210
+ ```python
211
+ from agentic_evals import PythonTarget, run_live_suite
212
+
213
+ report = run_live_suite(suite, PythonTarget(callable_path="my_module:run_case"))
214
+ ```
215
+
216
+ Live targets are intentionally powerful developer-facing integrations —
217
+ Python targets execute local code and HTTP targets can reach arbitrary
218
+ URLs. Only point them at trusted suite files and trusted target
219
+ definitions.
220
+
221
+ ## Using it with AgenticLens's own traces
222
+
223
+ If you already have an AgenticLens `Run` (from its instrumentation API or
224
+ OTLP ingestion), AgenticLens itself provides the adapter —
225
+ `agenticlens.evaluation.to_eval_trace(run)` — so you don't have to hand-build
226
+ an `EvalTrace`. This package has no dependency in the other direction.
227
+
228
+ ## What's deliberately not here
229
+
230
+ Dataset versioning/splitting, judge calibration, and HTML report rendering
231
+ stay in AgenticLens for now — those are product features built *on top of*
232
+ this engine (the same way Braintrust's dataset/experiment platform is
233
+ separate from the `autoevals` library itself), not the engine.
234
+
235
+ ## License
236
+
237
+ MIT
@@ -0,0 +1,10 @@
1
+ agentic_evals/__init__.py,sha256=F-ZnWxm5P89v86MKkBf3oaEWtdw4gJmLKK0hoNGRKS8,1531
2
+ agentic_evals/evaluators.py,sha256=gughdCUxseDsu5MqMepG2UKs-0kBiHP9On_mpcQL-W8,2791
3
+ agentic_evals/gate.py,sha256=0w0_uLILEeDn9GIsQ-xyav3TvNX5WoYlB7i34SzPit8,2384
4
+ agentic_evals/models.py,sha256=kh8ukzCuE9FAX9gEktOD4heicF0b3DdzCqDfBJ-rCMc,4700
5
+ agentic_evals/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ agentic_evals/runner.py,sha256=8z8la58EPw4EJvg-1yPIYMZdznicSdYU_uvomoJYJbE,15710
7
+ agentic_evals-0.1.0.dist-info/METADATA,sha256=irdrO4xK855W-vMz-CtrMWIAryDORuPOn558hsSChU8,8627
8
+ agentic_evals-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
9
+ agentic_evals-0.1.0.dist-info/licenses/LICENSE,sha256=MDZ9UDelXt06pT2XN55Fz1XHj4Tb4whEhX8hjBRnySg,1070
10
+ agentic_evals-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DeepAgentLabs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.