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.
- agentic_evals/__init__.py +69 -0
- agentic_evals/evaluators.py +95 -0
- agentic_evals/gate.py +61 -0
- agentic_evals/models.py +154 -0
- agentic_evals/py.typed +0 -0
- agentic_evals/runner.py +409 -0
- agentic_evals-0.1.0.dist-info/METADATA +237 -0
- agentic_evals-0.1.0.dist-info/RECORD +10 -0
- agentic_evals-0.1.0.dist-info/WHEEL +4 -0
- agentic_evals-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
+
)
|
agentic_evals/models.py
ADDED
|
@@ -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
|
agentic_evals/runner.py
ADDED
|
@@ -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,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.
|