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.
@@ -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
@@ -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