agentprdiff 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.
- agentprdiff/__init__.py +81 -0
- agentprdiff/cli.py +124 -0
- agentprdiff/core.py +217 -0
- agentprdiff/differ.py +161 -0
- agentprdiff/graders/__init__.py +38 -0
- agentprdiff/graders/deterministic.py +186 -0
- agentprdiff/graders/semantic.py +180 -0
- agentprdiff/loader.py +47 -0
- agentprdiff/reporters.py +127 -0
- agentprdiff/runner.py +130 -0
- agentprdiff/store.py +80 -0
- agentprdiff-0.1.0.dist-info/METADATA +200 -0
- agentprdiff-0.1.0.dist-info/RECORD +16 -0
- agentprdiff-0.1.0.dist-info/WHEEL +4 -0
- agentprdiff-0.1.0.dist-info/entry_points.txt +2 -0
- agentprdiff-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Deterministic graders — cheap, free, reproducible.
|
|
2
|
+
|
|
3
|
+
These never call an LLM. Prefer them whenever the assertion can be expressed
|
|
4
|
+
mechanically; reserve the semantic grader for things you genuinely can't
|
|
5
|
+
encode as a rule.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
|
|
13
|
+
from ..core import Grader, GradeResult, Trace
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _output_str(trace: Trace) -> str:
|
|
17
|
+
"""Best-effort stringification of the agent's final output."""
|
|
18
|
+
out = trace.output
|
|
19
|
+
if out is None:
|
|
20
|
+
return ""
|
|
21
|
+
if isinstance(out, str):
|
|
22
|
+
return out
|
|
23
|
+
try:
|
|
24
|
+
return str(out)
|
|
25
|
+
except Exception: # noqa: BLE001
|
|
26
|
+
return ""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def contains(substring: str, *, case_sensitive: bool = False) -> Grader:
|
|
30
|
+
"""Pass iff the agent's final output contains `substring`."""
|
|
31
|
+
|
|
32
|
+
def _grader(trace: Trace) -> GradeResult:
|
|
33
|
+
haystack = _output_str(trace)
|
|
34
|
+
passed = (
|
|
35
|
+
substring in haystack if case_sensitive else substring.lower() in haystack.lower()
|
|
36
|
+
)
|
|
37
|
+
return GradeResult(
|
|
38
|
+
passed=passed,
|
|
39
|
+
grader_name=f"contains({substring!r})",
|
|
40
|
+
reason=(
|
|
41
|
+
f"output {'contains' if passed else 'does not contain'} {substring!r}"
|
|
42
|
+
),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
return _grader
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def contains_any(substrings: Sequence[str], *, case_sensitive: bool = False) -> Grader:
|
|
49
|
+
"""Pass iff the output contains at least one of the listed substrings."""
|
|
50
|
+
|
|
51
|
+
def _grader(trace: Trace) -> GradeResult:
|
|
52
|
+
haystack = _output_str(trace) if case_sensitive else _output_str(trace).lower()
|
|
53
|
+
needles = list(substrings) if case_sensitive else [s.lower() for s in substrings]
|
|
54
|
+
matched = [n for n in needles if n in haystack]
|
|
55
|
+
passed = bool(matched)
|
|
56
|
+
return GradeResult(
|
|
57
|
+
passed=passed,
|
|
58
|
+
grader_name=f"contains_any({list(substrings)!r})",
|
|
59
|
+
reason=(
|
|
60
|
+
f"matched {matched!r}"
|
|
61
|
+
if passed
|
|
62
|
+
else f"none of {list(substrings)!r} found in output"
|
|
63
|
+
),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
return _grader
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def regex_match(pattern: str, *, flags: int = 0) -> Grader:
|
|
70
|
+
"""Pass iff `pattern` matches the agent's final output."""
|
|
71
|
+
compiled = re.compile(pattern, flags=flags)
|
|
72
|
+
|
|
73
|
+
def _grader(trace: Trace) -> GradeResult:
|
|
74
|
+
haystack = _output_str(trace)
|
|
75
|
+
m = compiled.search(haystack)
|
|
76
|
+
passed = m is not None
|
|
77
|
+
return GradeResult(
|
|
78
|
+
passed=passed,
|
|
79
|
+
grader_name=f"regex_match({pattern!r})",
|
|
80
|
+
reason=(
|
|
81
|
+
f"matched {m.group(0)!r}" if m else f"no match for {pattern!r}"
|
|
82
|
+
),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
return _grader
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def tool_called(name: str, *, min_times: int = 1) -> Grader:
|
|
89
|
+
"""Pass iff the tool `name` was called at least `min_times` times."""
|
|
90
|
+
|
|
91
|
+
def _grader(trace: Trace) -> GradeResult:
|
|
92
|
+
count = sum(1 for c in trace.tool_calls if c.name == name)
|
|
93
|
+
passed = count >= min_times
|
|
94
|
+
return GradeResult(
|
|
95
|
+
passed=passed,
|
|
96
|
+
grader_name=f"tool_called({name!r}, min_times={min_times})",
|
|
97
|
+
reason=f"tool {name!r} called {count} time(s), required >= {min_times}",
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
return _grader
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def no_tool_called(name: str) -> Grader:
|
|
104
|
+
"""Pass iff the tool `name` was NOT called."""
|
|
105
|
+
|
|
106
|
+
def _grader(trace: Trace) -> GradeResult:
|
|
107
|
+
count = sum(1 for c in trace.tool_calls if c.name == name)
|
|
108
|
+
passed = count == 0
|
|
109
|
+
return GradeResult(
|
|
110
|
+
passed=passed,
|
|
111
|
+
grader_name=f"no_tool_called({name!r})",
|
|
112
|
+
reason=f"tool {name!r} called {count} time(s); expected 0",
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
return _grader
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def tool_sequence(sequence: Sequence[str], *, strict: bool = False) -> Grader:
|
|
119
|
+
"""Pass iff the tool-call sequence matches `sequence`.
|
|
120
|
+
|
|
121
|
+
If `strict=False` (default), `sequence` must appear as a subsequence of
|
|
122
|
+
the actual tool calls (other tools may be interleaved). If `strict=True`,
|
|
123
|
+
the tool calls must equal `sequence` exactly.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
def _grader(trace: Trace) -> GradeResult:
|
|
127
|
+
actual = [c.name for c in trace.tool_calls]
|
|
128
|
+
if strict:
|
|
129
|
+
passed = actual == list(sequence)
|
|
130
|
+
else:
|
|
131
|
+
# subsequence check
|
|
132
|
+
i = 0
|
|
133
|
+
for call in actual:
|
|
134
|
+
if i < len(sequence) and call == sequence[i]:
|
|
135
|
+
i += 1
|
|
136
|
+
passed = i == len(sequence)
|
|
137
|
+
return GradeResult(
|
|
138
|
+
passed=passed,
|
|
139
|
+
grader_name=f"tool_sequence({list(sequence)!r}, strict={strict})",
|
|
140
|
+
reason=f"actual tool sequence: {actual}",
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
return _grader
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def output_length_lt(max_chars: int) -> Grader:
|
|
147
|
+
"""Pass iff the output has fewer than `max_chars` characters."""
|
|
148
|
+
|
|
149
|
+
def _grader(trace: Trace) -> GradeResult:
|
|
150
|
+
n = len(_output_str(trace))
|
|
151
|
+
passed = n < max_chars
|
|
152
|
+
return GradeResult(
|
|
153
|
+
passed=passed,
|
|
154
|
+
grader_name=f"output_length_lt({max_chars})",
|
|
155
|
+
reason=f"output length {n} chars, limit {max_chars}",
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
return _grader
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def latency_lt_ms(max_ms: float) -> Grader:
|
|
162
|
+
"""Pass iff the trace's total latency is below `max_ms` milliseconds."""
|
|
163
|
+
|
|
164
|
+
def _grader(trace: Trace) -> GradeResult:
|
|
165
|
+
passed = trace.total_latency_ms < max_ms
|
|
166
|
+
return GradeResult(
|
|
167
|
+
passed=passed,
|
|
168
|
+
grader_name=f"latency_lt_ms({max_ms})",
|
|
169
|
+
reason=f"latency {trace.total_latency_ms:.1f} ms, limit {max_ms:.1f} ms",
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
return _grader
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def cost_lt_usd(max_usd: float) -> Grader:
|
|
176
|
+
"""Pass iff the trace's total cost is below `max_usd` dollars."""
|
|
177
|
+
|
|
178
|
+
def _grader(trace: Trace) -> GradeResult:
|
|
179
|
+
passed = trace.total_cost_usd < max_usd
|
|
180
|
+
return GradeResult(
|
|
181
|
+
passed=passed,
|
|
182
|
+
grader_name=f"cost_lt_usd({max_usd})",
|
|
183
|
+
reason=f"cost ${trace.total_cost_usd:.4f}, limit ${max_usd:.4f}",
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
return _grader
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Semantic grader — LLM-as-judge.
|
|
2
|
+
|
|
3
|
+
The `semantic` grader accepts a rubric (natural language) and a `judge`
|
|
4
|
+
callable that returns a pass/fail verdict with a reason. We ship two built-in
|
|
5
|
+
judges:
|
|
6
|
+
|
|
7
|
+
* `fake_judge` — deterministic; used in tests and CI when you want a green
|
|
8
|
+
pipeline without API keys. It passes iff any rubric keyword appears in the
|
|
9
|
+
agent's output.
|
|
10
|
+
* `openai_judge(model=...)` and `anthropic_judge(model=...)` — thin wrappers
|
|
11
|
+
over the respective SDKs. Imported lazily so agentprdiff has no required
|
|
12
|
+
runtime dependency on either SDK.
|
|
13
|
+
|
|
14
|
+
Custom judges are encouraged. A judge is a callable:
|
|
15
|
+
|
|
16
|
+
judge(rubric: str, trace: Trace) -> (passed: bool, reason: str)
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
from collections.abc import Callable
|
|
23
|
+
|
|
24
|
+
from ..core import Grader, GradeResult, Trace
|
|
25
|
+
|
|
26
|
+
Judge = Callable[[str, Trace], tuple[bool, str]]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
# Public grader.
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def semantic(rubric: str, *, judge: Judge | None = None) -> Grader:
|
|
35
|
+
"""Pass iff the `judge` says the trace satisfies the `rubric`.
|
|
36
|
+
|
|
37
|
+
The rubric is natural language, e.g. "the agent acknowledged the refund
|
|
38
|
+
and provided a ticket number".
|
|
39
|
+
"""
|
|
40
|
+
backend = judge or _default_judge()
|
|
41
|
+
|
|
42
|
+
def _grader(trace: Trace) -> GradeResult:
|
|
43
|
+
try:
|
|
44
|
+
passed, reason = backend(rubric, trace)
|
|
45
|
+
except Exception as exc: # noqa: BLE001
|
|
46
|
+
return GradeResult(
|
|
47
|
+
passed=False,
|
|
48
|
+
grader_name=f"semantic({rubric!r})",
|
|
49
|
+
reason=f"judge raised {type(exc).__name__}: {exc}",
|
|
50
|
+
)
|
|
51
|
+
return GradeResult(
|
|
52
|
+
passed=passed,
|
|
53
|
+
grader_name=f"semantic({rubric!r})",
|
|
54
|
+
reason=reason,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
return _grader
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
# Built-in judges.
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def fake_judge(rubric: str, trace: Trace) -> tuple[bool, str]:
|
|
66
|
+
"""Keyword-match judge for tests. Passes iff ANY rubric word (>= 4 chars)
|
|
67
|
+
appears in the agent output, case-insensitive.
|
|
68
|
+
|
|
69
|
+
Deterministic, free, and good enough for demos and CI smoke tests. Do
|
|
70
|
+
not use in production eval pipelines.
|
|
71
|
+
"""
|
|
72
|
+
output = str(trace.output or "").lower()
|
|
73
|
+
keywords = [w for w in _tokenize(rubric) if len(w) >= 4]
|
|
74
|
+
matched = [w for w in keywords if w in output]
|
|
75
|
+
passed = bool(matched)
|
|
76
|
+
reason = (
|
|
77
|
+
f"fake_judge matched keywords {matched}" if passed else "fake_judge matched no keywords"
|
|
78
|
+
)
|
|
79
|
+
return passed, reason
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _tokenize(text: str) -> list[str]:
|
|
83
|
+
import re
|
|
84
|
+
|
|
85
|
+
return re.findall(r"[a-zA-Z]+", text.lower())
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
_JUDGE_PROMPT = (
|
|
89
|
+
"You are an evaluator. Given a RUBRIC and an agent's OUTPUT, decide "
|
|
90
|
+
"whether the output satisfies the rubric.\n\n"
|
|
91
|
+
"Respond with the single word PASS or FAIL on the first line, and a "
|
|
92
|
+
"one-sentence reason on the second line. Be strict: if the rubric is "
|
|
93
|
+
"not clearly satisfied, answer FAIL.\n\n"
|
|
94
|
+
"RUBRIC:\n{rubric}\n\n"
|
|
95
|
+
"OUTPUT:\n{output}\n"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def openai_judge(model: str = "gpt-4o-mini", api_key: str | None = None) -> Judge:
|
|
100
|
+
"""Return a judge backed by the OpenAI Chat Completions API.
|
|
101
|
+
|
|
102
|
+
Requires the `openai` package (install with `pip install agentprdiff[openai]`).
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def _judge(rubric: str, trace: Trace) -> tuple[bool, str]:
|
|
106
|
+
from openai import OpenAI # lazy import
|
|
107
|
+
|
|
108
|
+
client = OpenAI(api_key=api_key or os.environ.get("OPENAI_API_KEY"))
|
|
109
|
+
prompt = _JUDGE_PROMPT.format(rubric=rubric, output=str(trace.output or ""))
|
|
110
|
+
resp = client.chat.completions.create(
|
|
111
|
+
model=model,
|
|
112
|
+
messages=[{"role": "user", "content": prompt}],
|
|
113
|
+
temperature=0,
|
|
114
|
+
)
|
|
115
|
+
text = resp.choices[0].message.content or ""
|
|
116
|
+
return _parse_verdict(text)
|
|
117
|
+
|
|
118
|
+
return _judge
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def anthropic_judge(model: str = "claude-haiku-4-5-20251001", api_key: str | None = None) -> Judge:
|
|
122
|
+
"""Return a judge backed by the Anthropic Messages API.
|
|
123
|
+
|
|
124
|
+
Requires the `anthropic` package (install with `pip install agentprdiff[anthropic]`).
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
def _judge(rubric: str, trace: Trace) -> tuple[bool, str]:
|
|
128
|
+
import anthropic # lazy import
|
|
129
|
+
|
|
130
|
+
client = anthropic.Anthropic(api_key=api_key or os.environ.get("ANTHROPIC_API_KEY"))
|
|
131
|
+
prompt = _JUDGE_PROMPT.format(rubric=rubric, output=str(trace.output or ""))
|
|
132
|
+
resp = client.messages.create(
|
|
133
|
+
model=model,
|
|
134
|
+
max_tokens=120,
|
|
135
|
+
messages=[{"role": "user", "content": prompt}],
|
|
136
|
+
)
|
|
137
|
+
text = "".join(
|
|
138
|
+
block.text for block in resp.content if getattr(block, "type", None) == "text"
|
|
139
|
+
)
|
|
140
|
+
return _parse_verdict(text)
|
|
141
|
+
|
|
142
|
+
return _judge
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _parse_verdict(text: str) -> tuple[bool, str]:
|
|
146
|
+
lines = [ln.strip() for ln in (text or "").strip().splitlines() if ln.strip()]
|
|
147
|
+
if not lines:
|
|
148
|
+
return False, "judge returned empty response"
|
|
149
|
+
verdict = lines[0].upper()
|
|
150
|
+
reason = lines[1] if len(lines) > 1 else ""
|
|
151
|
+
if verdict.startswith("PASS"):
|
|
152
|
+
return True, reason or "judge said PASS"
|
|
153
|
+
if verdict.startswith("FAIL"):
|
|
154
|
+
return False, reason or "judge said FAIL"
|
|
155
|
+
# Be strict: anything other than an explicit PASS is a FAIL.
|
|
156
|
+
return False, f"judge returned unparseable verdict: {lines[0]!r}"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ---------------------------------------------------------------------------
|
|
160
|
+
# Backend selection.
|
|
161
|
+
# ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _default_judge() -> Judge:
|
|
165
|
+
"""Pick a default judge based on environment.
|
|
166
|
+
|
|
167
|
+
Order of preference:
|
|
168
|
+
* AGENTGUARD_JUDGE=fake -> fake_judge
|
|
169
|
+
* AGENTGUARD_JUDGE=openai or OPENAI_API_KEY set -> openai_judge()
|
|
170
|
+
* AGENTGUARD_JUDGE=anthropic or ANTHROPIC_API_KEY set -> anthropic_judge()
|
|
171
|
+
* otherwise -> fake_judge (so pipelines stay green in CI without keys)
|
|
172
|
+
"""
|
|
173
|
+
choice = (os.environ.get("AGENTGUARD_JUDGE") or "").lower()
|
|
174
|
+
if choice == "fake":
|
|
175
|
+
return fake_judge
|
|
176
|
+
if choice == "openai" or (not choice and os.environ.get("OPENAI_API_KEY")):
|
|
177
|
+
return openai_judge()
|
|
178
|
+
if choice == "anthropic" or (not choice and os.environ.get("ANTHROPIC_API_KEY")):
|
|
179
|
+
return anthropic_judge()
|
|
180
|
+
return fake_judge
|
agentprdiff/loader.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Load `Suite` objects from user-provided Python files.
|
|
2
|
+
|
|
3
|
+
We deliberately keep this ultra-simple for v0.1: the user points at a python
|
|
4
|
+
file path (or module) and we import it; every module-level `Suite` instance
|
|
5
|
+
is a suite to run.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
import importlib.util
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from .core import Suite
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def load_suites(path: str | Path) -> list[Suite]:
|
|
19
|
+
"""Import `path` and return every module-level `Suite` it defines."""
|
|
20
|
+
p = Path(path).resolve()
|
|
21
|
+
if not p.exists():
|
|
22
|
+
raise FileNotFoundError(f"no such file: {p}")
|
|
23
|
+
if p.is_dir():
|
|
24
|
+
raise IsADirectoryError(
|
|
25
|
+
f"{p} is a directory; point at a .py file that defines Suites."
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
module_name = f"_agentprdiff_suite_{abs(hash(str(p)))}"
|
|
29
|
+
spec = importlib.util.spec_from_file_location(module_name, p)
|
|
30
|
+
if spec is None or spec.loader is None: # pragma: no cover
|
|
31
|
+
raise ImportError(f"could not load suite file: {p}")
|
|
32
|
+
module = importlib.util.module_from_spec(spec)
|
|
33
|
+
# Ensure the file's own directory is importable (for relative helpers).
|
|
34
|
+
sys.path.insert(0, str(p.parent))
|
|
35
|
+
try:
|
|
36
|
+
spec.loader.exec_module(module)
|
|
37
|
+
finally:
|
|
38
|
+
with contextlib.suppress(ValueError):
|
|
39
|
+
sys.path.remove(str(p.parent))
|
|
40
|
+
|
|
41
|
+
suites = [v for v in vars(module).values() if isinstance(v, Suite)]
|
|
42
|
+
if not suites:
|
|
43
|
+
raise ValueError(
|
|
44
|
+
f"{p} defines no module-level Suite objects. "
|
|
45
|
+
"Use `from agentprdiff import suite` and bind the result to a variable."
|
|
46
|
+
)
|
|
47
|
+
return suites
|
agentprdiff/reporters.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Reporters — render RunReports for humans and for CI.
|
|
2
|
+
|
|
3
|
+
`TerminalReporter` uses rich for pretty output. `JsonReporter` writes a
|
|
4
|
+
stable JSON envelope you can archive as a CI artifact.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.panel import Panel
|
|
14
|
+
from rich.table import Table
|
|
15
|
+
from rich.text import Text
|
|
16
|
+
|
|
17
|
+
from .runner import RunReport
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TerminalReporter:
|
|
21
|
+
def __init__(self, console: Console | None = None) -> None:
|
|
22
|
+
self.console = console or Console()
|
|
23
|
+
|
|
24
|
+
def render(self, report: RunReport) -> None:
|
|
25
|
+
header = Text()
|
|
26
|
+
header.append(f"agentprdiff {report.mode} ", style="bold cyan")
|
|
27
|
+
header.append("— suite ", style="dim")
|
|
28
|
+
header.append(f"{report.suite_name}", style="bold")
|
|
29
|
+
header.append(
|
|
30
|
+
f" ({report.cases_passed}/{report.cases_total} passed, "
|
|
31
|
+
f"{report.cases_regressed} regressed)",
|
|
32
|
+
style="dim",
|
|
33
|
+
)
|
|
34
|
+
self.console.print(header)
|
|
35
|
+
|
|
36
|
+
table = Table(show_header=True, header_style="bold", show_lines=False, expand=True)
|
|
37
|
+
table.add_column("Case", style="bold")
|
|
38
|
+
table.add_column("Result")
|
|
39
|
+
table.add_column("Cost Δ", justify="right")
|
|
40
|
+
table.add_column("Latency Δ", justify="right")
|
|
41
|
+
table.add_column("Notes")
|
|
42
|
+
|
|
43
|
+
for cr in report.case_reports:
|
|
44
|
+
if cr.has_regression:
|
|
45
|
+
result = Text("REGRESSION", style="bold red")
|
|
46
|
+
elif cr.passed:
|
|
47
|
+
result = Text("PASS", style="bold green")
|
|
48
|
+
else:
|
|
49
|
+
result = Text("FAIL", style="bold red")
|
|
50
|
+
|
|
51
|
+
cost_cell = ""
|
|
52
|
+
latency_cell = ""
|
|
53
|
+
notes = []
|
|
54
|
+
if cr.delta is not None:
|
|
55
|
+
if cr.delta.cost_delta_usd:
|
|
56
|
+
cost_cell = _format_delta(cr.delta.cost_delta_usd, "${:+.4f}")
|
|
57
|
+
if cr.delta.latency_delta_ms:
|
|
58
|
+
latency_cell = _format_delta(cr.delta.latency_delta_ms, "{:+.0f} ms")
|
|
59
|
+
if cr.delta.tool_sequence_changed:
|
|
60
|
+
notes.append(
|
|
61
|
+
"tools: "
|
|
62
|
+
f"{cr.delta.baseline_tool_sequence} → "
|
|
63
|
+
f"{cr.delta.current_tool_sequence}"
|
|
64
|
+
)
|
|
65
|
+
if cr.delta.output_changed and not cr.has_regression:
|
|
66
|
+
notes.append("output changed")
|
|
67
|
+
for ac in cr.delta.regressions:
|
|
68
|
+
notes.append(f"[red]{ac.grader_name}[/red] {ac.current_reason}")
|
|
69
|
+
if cr.trace.error:
|
|
70
|
+
notes.append(f"[red]error:[/red] {cr.trace.error}")
|
|
71
|
+
for r in cr.grader_results:
|
|
72
|
+
if not r.passed and cr.delta is None:
|
|
73
|
+
notes.append(f"[red]{r.grader_name}[/red] {r.reason}")
|
|
74
|
+
|
|
75
|
+
table.add_row(cr.case_name, result, cost_cell, latency_cell, "\n".join(notes) or "—")
|
|
76
|
+
|
|
77
|
+
self.console.print(table)
|
|
78
|
+
|
|
79
|
+
# Per-regression expanded section.
|
|
80
|
+
for cr in report.case_reports:
|
|
81
|
+
if cr.has_regression and cr.delta is not None and cr.delta.output_diff:
|
|
82
|
+
self.console.print(
|
|
83
|
+
Panel(
|
|
84
|
+
cr.delta.output_diff,
|
|
85
|
+
title=f"{cr.case_name}: output diff",
|
|
86
|
+
border_style="red",
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
if report.mode == "check":
|
|
91
|
+
if report.has_regression:
|
|
92
|
+
self.console.print(
|
|
93
|
+
Text(
|
|
94
|
+
f"\n✗ {report.cases_regressed} regression(s) detected.",
|
|
95
|
+
style="bold red",
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
else:
|
|
99
|
+
self.console.print(Text("\n✓ no regressions.", style="bold green"))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class JsonReporter:
|
|
103
|
+
"""Write a stable JSON envelope suitable for CI artifact archiving."""
|
|
104
|
+
|
|
105
|
+
def render(self, report: RunReport, path: Path) -> Path:
|
|
106
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
107
|
+
payload = {
|
|
108
|
+
"suite": report.suite_name,
|
|
109
|
+
"mode": report.mode,
|
|
110
|
+
"summary": {
|
|
111
|
+
"cases_total": report.cases_total,
|
|
112
|
+
"cases_passed": report.cases_passed,
|
|
113
|
+
"cases_regressed": report.cases_regressed,
|
|
114
|
+
"has_regression": report.has_regression,
|
|
115
|
+
},
|
|
116
|
+
"cases": [cr.model_dump(mode="json") for cr in report.case_reports],
|
|
117
|
+
}
|
|
118
|
+
path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
|
119
|
+
return path
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _format_delta(value: float, fmt: str) -> str:
|
|
123
|
+
if value == 0:
|
|
124
|
+
return ""
|
|
125
|
+
text = fmt.format(value)
|
|
126
|
+
color = "green" if value < 0 else "red"
|
|
127
|
+
return f"[{color}]{text}[/{color}]"
|
agentprdiff/runner.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Run a Suite — the heart of agentprdiff.
|
|
2
|
+
|
|
3
|
+
Two modes:
|
|
4
|
+
|
|
5
|
+
* `record` — run each case, save the resulting `Trace` as the baseline, do
|
|
6
|
+
not compare.
|
|
7
|
+
* `check` — run each case, load the baseline (if any), compute a `TraceDelta`,
|
|
8
|
+
aggregate into a `RunReport`. Exit status at the CLI is driven by
|
|
9
|
+
`RunReport.has_regression`.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
15
|
+
|
|
16
|
+
from .core import GradeResult, Suite, Trace, run_agent
|
|
17
|
+
from .differ import TraceDelta, diff_traces
|
|
18
|
+
from .store import BaselineStore
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CaseReport(BaseModel):
|
|
22
|
+
"""Per-case outcome within a RunReport."""
|
|
23
|
+
|
|
24
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
25
|
+
|
|
26
|
+
suite_name: str
|
|
27
|
+
case_name: str
|
|
28
|
+
trace: Trace
|
|
29
|
+
grader_results: list[GradeResult]
|
|
30
|
+
delta: TraceDelta | None = None
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def passed(self) -> bool:
|
|
34
|
+
"""All graders passed for the current run."""
|
|
35
|
+
return all(r.passed for r in self.grader_results) and self.trace.error is None
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def has_regression(self) -> bool:
|
|
39
|
+
"""Whether this case regressed vs baseline. If there is no baseline,
|
|
40
|
+
we treat a full pass as not-a-regression; any failing grader is
|
|
41
|
+
treated as a regression (first-run-bad is still bad)."""
|
|
42
|
+
if not self.passed:
|
|
43
|
+
return True
|
|
44
|
+
if self.delta is not None:
|
|
45
|
+
return self.delta.has_regression
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class RunReport(BaseModel):
|
|
50
|
+
"""Aggregate result of running a suite."""
|
|
51
|
+
|
|
52
|
+
suite_name: str
|
|
53
|
+
mode: str # "record" or "check"
|
|
54
|
+
case_reports: list[CaseReport] = Field(default_factory=list)
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def cases_passed(self) -> int:
|
|
58
|
+
return sum(1 for c in self.case_reports if c.passed)
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def cases_total(self) -> int:
|
|
62
|
+
return len(self.case_reports)
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def cases_regressed(self) -> int:
|
|
66
|
+
return sum(1 for c in self.case_reports if c.has_regression)
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def has_regression(self) -> bool:
|
|
70
|
+
return any(c.has_regression for c in self.case_reports)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Runner:
|
|
74
|
+
"""Runs suites in record or check mode."""
|
|
75
|
+
|
|
76
|
+
def __init__(self, store: BaselineStore) -> None:
|
|
77
|
+
self.store = store
|
|
78
|
+
|
|
79
|
+
# ------------------------------------------------------------------ api
|
|
80
|
+
|
|
81
|
+
def record(self, suite: Suite) -> RunReport:
|
|
82
|
+
return self._run(suite, mode="record")
|
|
83
|
+
|
|
84
|
+
def check(self, suite: Suite) -> RunReport:
|
|
85
|
+
return self._run(suite, mode="check")
|
|
86
|
+
|
|
87
|
+
# --------------------------------------------------------------- impl
|
|
88
|
+
|
|
89
|
+
def _run(self, suite: Suite, *, mode: str) -> RunReport:
|
|
90
|
+
self.store.ensure_initialized()
|
|
91
|
+
run_id = self.store.fresh_run_id()
|
|
92
|
+
report = RunReport(suite_name=suite.name, mode=mode)
|
|
93
|
+
|
|
94
|
+
for case in suite.cases:
|
|
95
|
+
trace = run_agent(
|
|
96
|
+
suite.agent,
|
|
97
|
+
suite_name=suite.name,
|
|
98
|
+
case_name=case.name,
|
|
99
|
+
input_value=case.input,
|
|
100
|
+
)
|
|
101
|
+
grader_results = [g(trace) for g in case.expect]
|
|
102
|
+
# Persist the current run either way (record = baseline, check = runs/).
|
|
103
|
+
delta: TraceDelta | None = None
|
|
104
|
+
if mode == "record":
|
|
105
|
+
self.store.save_baseline(trace)
|
|
106
|
+
else:
|
|
107
|
+
self.store.save_run_trace(run_id, trace)
|
|
108
|
+
baseline = self.store.load_baseline(suite.name, case.name)
|
|
109
|
+
baseline_results = None
|
|
110
|
+
if baseline is not None:
|
|
111
|
+
# Re-run graders against the baseline so the delta's
|
|
112
|
+
# per-assertion regression flags are accurate.
|
|
113
|
+
baseline_results = [g(baseline) for g in case.expect]
|
|
114
|
+
delta = diff_traces(
|
|
115
|
+
baseline=baseline,
|
|
116
|
+
current=trace,
|
|
117
|
+
current_results=grader_results,
|
|
118
|
+
baseline_results=baseline_results,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
report.case_reports.append(
|
|
122
|
+
CaseReport(
|
|
123
|
+
suite_name=suite.name,
|
|
124
|
+
case_name=case.name,
|
|
125
|
+
trace=trace,
|
|
126
|
+
grader_results=grader_results,
|
|
127
|
+
delta=delta,
|
|
128
|
+
)
|
|
129
|
+
)
|
|
130
|
+
return report
|