Skip to content

feat(evals): agent eval harness + finding_enricher reference implementation (ADR-0050)#265

Merged
galanko merged 8 commits into
mainfrom
feat/eval-finding-enricher
Jun 9, 2026
Merged

feat(evals): agent eval harness + finding_enricher reference implementation (ADR-0050)#265
galanko merged 8 commits into
mainfrom
feat/eval-finding-enricher

Conversation

@galanko

@galanko galanko commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator

User description

First agent wired under the generic eval system (ADR-0050, cliff-os#7). Rollout order = highest-risk first, so finding_enricher is the reference implementation that proves the abstraction end-to-end.

What's here

A thin layer over pydantic-evals (already in the tree):

  • cliff/evals/adapter (drives any workspace agent through one call), registry (AgentEvalSpec: supported assertions + budget + model), cases (typed JSONL loader), evaluators (deterministic — "code first, judge last").
  • The citation gate is split (data-driven — the live run taught us this): a structurally-impossible reference (fake GHSA / garbled SHA / non-http) is the zero-tolerance hard gate, reusing the production reference_verifier.clean_references; a plausible URL that 404s is a graded dead-link metric, not a hard fail (production strips it anyway, and even good models occasionally guess a moved doc URL).
  • Two lanes (ADR-0050 §5):
    • test_evals_harness.pyCI, deterministic (FunctionModel/TestModel + injected http). Proves the scorer is correct without a key, so a green CI means the harness won't silently mis-score.
    • test_evals_finding_enricher.pylive, key-gated (via conftest). Runs the real enricher over eval/finding_enricher.jsonl: hard gates zero-tolerance, graded metrics on a floor.
  • Dataset — 6 cases: well-known CVE (log4shell), dependency CVE (lodash), scanner-jargon title (minimist), two no-CVE abstention cases (Semgrep eval-injection, posture missing-header), and a fabrication-bait case (obscure private package).

Test plan / evidence

  • CI lane: 9/9; broader deterministic agent suite: 104 passed; ruff clean.
  • Live lane: passes against real gpt-4o-mini — no structural fabrication, abstention holds on all no-CVE cases, graded metrics clear the 60% floor. (The live run also caught gpt-4o-mini guessing dead doc URLs — which is exactly why the gate was split.)

Notes

🤖 Generated with Claude Code


Generated description

Below is a concise technical summary of the changes proposed in this PR:
Deliver an end-to-end agent eval harness that wires run_agent/run_agent_measured through AgentEvalSpec, budget/pricing guards, custom evaluators, and the run_enricher_eval runner so the finding_enricher reference implementation can be exercised deterministically (CI) and with real models (live) under ADR-0050. Coordinate dataset discoverability, live-vs-CI gating, and public sample cases so the same harness drives the open-source deterministic lane, the gated live lane, and the private cliff-os runner while keeping the shared tooling versioned in cliff.evals.

TopicDetails
Packaging Ensure the package metadata (pyproject.toml wheel target and editable uv.lock entry) publishes the new cliff package so the harness can be consumed from both the repo and downstream eval runner.
Modified files (2)
  • backend/pyproject.toml
  • backend/uv.lock
Latest Contributors(1)
UserCommitDate
galank@gmail.comfeat(evals): enforce p...June 09, 2026
Harness core Connect cliff.evals.adapter, registry, models, pricing, evaluators, and runners into a single harness that resolves models, budgets, evaluator gates, and scoring loops for finding_enricher so ADR-0050’s CI/live dual lanes share the same orchestration.
Modified files (7)
  • backend/cliff/evals/__init__.py
  • backend/cliff/evals/adapter.py
  • backend/cliff/evals/evaluators.py
  • backend/cliff/evals/models.py
  • backend/cliff/evals/pricing.py
  • backend/cliff/evals/registry.py
  • backend/cliff/evals/runners.py
Latest Contributors(1)
UserCommitDate
galank@gmail.comfix(evals): address se...June 09, 2026
Dataset & tests Document and load the JSONL eval dataset, reuse the canonical env/model gating, and assert CI/live behaviors via deterministic (test_evals_harness.py) and live (test_evals_finding_enricher.py) tests so the harness always validates the same flows and datasets the runner uses.
Modified files (7)
  • backend/cliff/evals/cases.py
  • backend/tests/agents/conftest.py
  • backend/tests/agents/eval/README.md
  • backend/tests/agents/eval/finding_enricher.jsonl
  • backend/tests/agents/eval_utils.py
  • backend/tests/agents/test_evals_finding_enricher.py
  • backend/tests/agents/test_evals_harness.py
Latest Contributors(1)
UserCommitDate
galank@gmail.comfix(evals): address se...June 09, 2026
Review this PR on Baz | Customize your next review

…R-0050)

First agent wired under the generic eval system (ADR-0050 rollout: highest-
risk first). Thin layer over pydantic-evals:

- cliff/evals/: adapter (drives any workspace agent through one call),
  registry (AgentEvalSpec — supported assertions + budget + model), cases
  (typed JSONL loader), evaluators (deterministic; "code first, judge last").
- The citation gate is SPLIT (data-driven, the live run taught us this):
  structural fabrication (fake GHSA / garbled SHA / non-http) is the
  zero-tolerance HARD gate (reuses the production reference_verifier);
  a plausible URL that 404s is a GRADED dead-link metric, not a hard fail.
- Two lanes (ADR-0050 §5): test_evals_harness.py (CI, deterministic,
  FunctionModel/TestModel + injected http — proves the scorer);
  test_evals_finding_enricher.py (live, key-gated via conftest — runs the
  real enricher over eval/finding_enricher.jsonl: hard gates zero-tolerance,
  graded metrics on a floor).
- Dataset: 6 cases covering well-known CVE, dependency CVE, scanner-jargon
  title, two no-CVE abstention cases, and a fabrication-bait case.

Verified: CI lane 9/9 + 104 deterministic agent tests green; the live lane
passes against real gpt-4o-mini (no structural fabrication, abstention holds,
graded metrics clear the floor).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 8, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@galanko, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 19 minutes and 21 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: e2ddf049-3dd0-42dd-a45a-9a15c4f42ca3

📥 Commits

Reviewing files that changed from the base of the PR and between af806fc and c5a05f2.

📒 Files selected for processing (10)
  • backend/cliff/evals/__init__.py
  • backend/cliff/evals/adapter.py
  • backend/cliff/evals/cases.py
  • backend/cliff/evals/evaluators.py
  • backend/cliff/evals/models.py
  • backend/cliff/evals/pricing.py
  • backend/cliff/evals/registry.py
  • backend/cliff/evals/runners.py
  • backend/tests/agents/conftest.py
  • backend/tests/agents/eval_utils.py
📝 Walkthrough

Walkthrough

This PR introduces the Cliff agent-evaluation harness (ADR-0050): spec registry, JSONL case loader, deterministic evaluators for citations/CVEs/CVSS/title/abstention, an adapter to run agents under eval conditions, a runner to aggregate graded and hard-fail metrics, and CI/live tests with sample fixtures.

Changes

Agent Evaluation Harness (ADR-0050)

Layer / File(s) Summary
Eval spec definition and registry
backend/cliff/evals/registry.py
BudgetSpec and AgentEvalSpec dataclasses define agent eval constraints and supported assertions; registry maps finding_enricher and provides get_spec/all_specs.
Test case model and dataset loading
backend/cliff/evals/cases.py, backend/tests/agents/eval/finding_enricher.jsonl
EvalCase Pydantic model, dataset_dir() lookup, and load_cases read and validate per-agent JSONL fixtures (skips blank/// lines, reports file/line on errors). Sample dataset includes six cases (CVE-backed and abstain scenarios).
Deterministic evaluation helpers
backend/cliff/evals/evaluators.py
Deterministic checks: CVE normalization, assess_references to classify dropped citations as structural vs network, hard gate check_structural_citations, graded check_reference_liveness, and validators check_cve_ids, check_cvss_within, check_no_jargon_title, check_abstention. Optional pydantic_evals wrappers included.
Agent execution adapter
backend/cliff/evals/adapter.py
run_agent resolves or accepts a model, builds an agent from an AgentEvalSpec, creates eval WorkspaceDeps, renders the prompt, executes the agent, and returns structured result.output. run_agent_measured returns AgentRun with tokens and duration.
Eval runner and result aggregation
backend/cliff/evals/runners.py
EvalRunResult dataclass and run_enricher_eval run finding_enricher across cases, apply evaluators per case, collect hard failures and per-metric graded rates, enforce per-case/per-run budgets, and return a summarized result with passed boolean and report().
Package entrypoint and public API
backend/cliff/evals/__init__.py
Package init documents CI vs Live lanes and re-exports public API: run_agent, EvalCase, dataset_dir, load_cases, AgentEvalSpec, get_spec, EvalRunResult, run_enricher_eval.
Deterministic CI harness tests
backend/tests/agents/test_evals_harness.py
CI tests with a fake HTTP client validate evaluator semantics (fabrication vs 404), CVE/CVSS/jargon/abstention rules, adapter wiring via TestModel, and dataset/spec conformance.
Live evaluation test
backend/tests/agents/test_evals_finding_enricher.py, backend/tests/agents/conftest.py
Live-lane test loads tier="live" cases and runs run_enricher_eval with real-model env when available; test is conditionally skipped without provider env vars. conftest updated to include additional provider env vars and the live eval test module.
Public sample dataset docs
backend/tests/agents/eval/README.md
Documents the public synthetic eval datasets, CLIFF_EVAL_DATASET_DIR override behavior, and contribution guidance for public sample cases.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • cliff-security/cliff#221: Introduces runtime components (WorkspaceDeps, build_user_prompt, agent substrate) that the eval harness adapter wires and depends on.
  • cliff-security/cliff#254: Modifies test live-eval gating logic in conftest.py similar to the provider-env changes in this PR.

Poem

🐰 A harness for checks, both careful and bright,

citations and CVEs given clear sight,
CI runs deterministic, tests stay true,
live lanes wake when provider keys come through,
the enricher hops forward — vetted and new.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 46.51% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: implementing an agent evaluation harness and the finding_enricher reference implementation per ADR-0050.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/eval-finding-enricher

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Comment thread backend/cliff/evals/cases.py Outdated
edge_case: str | None = None
abstain: bool = False
finding: dict[str, Any]
expected: dict[str, Any] = Field(default_factory=dict)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

expected is still a free-form dict[str, Any], so malformed JSONL rows can reach check_cve_ids()/check_cvss_within() and sometimes pass silently; should we replace it with a typed submodel or validators so bad rows fail in load_cases()?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
backend/cliff/evals/cases.py around lines 23-53, refactor the EvalCase model so the
expected field is no longer a free-form dict[str, Any]. Replace expected: dict[str, Any]
(line 33) with a typed Pydantic submodel (or add field validators) that explicitly
validates the evaluator contract (e.g., expected.cve_ids must be a list of strings, and
expected.cvss must be numeric and within any required bounds), so malformed JSONL rows
fail during EvalCase.model_validate_json. Ensure load_cases (lines 36-52) still surfaces
the same {path.name}:{line_no} context on validation errors, but now the errors are
raised for bad expectation shapes/types instead of leaking into
check_cve_ids()/check_cvss_within().

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Commit af806fc addressed this comment by replacing expected with a typed Expected Pydantic submodel. The new model forbids extra keys and constrains the expectation fields, so malformed JSONL rows now fail during load_cases() instead of flowing through to check_cve_ids() / check_cvss_within().

Comment on lines +63 to +69
refs = getattr(output, "references", None)
check = (
await clean_references(refs, http=http)
if http is not None
else await clean_references(refs)
)
structural = [(u, r) for u, r in check.dropped if not r.startswith("http_")]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if http is not None is redundant since both branches call clean_references(refs, http=http); should we drop the branch and just check = await clean_references(refs, http=http)?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
backend/cliff/evals/evaluators.py around lines 63-69 inside the async function
assess_references, remove the redundant if/else that checks `http is not None`. Refactor
it so you always call `clean_references(refs, http=http)` (letting http be None), since
clean_references already defaults `http` to None. Ensure the returned
`ReferenceAssessment` still uses the same `check.dropped` and `check.kept` logic.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Commit af806fc addressed this comment by removing the if http is not None branch and always calling clean_references(refs, http=http). The returned ReferenceAssessment still uses the same check.dropped and check.kept values afterward.

Comment on lines +25 to +29
_LIVE_LLM_FILES = (
"test_normalizer_agent",
"test_plain_description_eval",
"test_evals_finding_enricher",
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test_evals_finding_enricher is treated as live-LLM, but _api_key_set still only checks OPENAI_API_KEY/ANTHROPIC_API_KEY, so setups like OPENROUTER_API_KEY + CLIFF_EVAL_MODEL=openrouter/... get skipped; should we widen the gate to the same provider set as build_model() or derive it from LLM_ENV/LLM_MODEL?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
`backend/tests/agents/conftest.py` around lines 25-29, `_LIVE_LLM_FILES` now includes
`test_evals_finding_enricher`, but the live-LLM gating logic via `_api_key_set` still
only recognizes `OPENAI_API_KEY` and `ANTHROPIC_API_KEY`, so tests are incorrectly
skipped for other `build_model()`-supported providers (e.g., `OPENROUTER_API_KEY`,
`GEMINI_API_KEY`, `OLLAMA_BASE_URL` with a model like `openrouter/...`). Refactor by
updating `_api_key_set` (or the surrounding skip condition in this file) to use the same
provider set as `build_model()`—either by checking all relevant provider env vars or
by inferring the needed gate from `CLIFF_EVAL_MODEL`/`LLM_ENV`/`LLM_MODEL`. Ensure that
when the environment is runnable with a non-OpenAI/Anthropic config,
`test_evals_finding_enricher` is no longer skipped.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Commit af806fc addressed this comment by broadening _api_key_set to recognize additional LLM provider env vars (OPENROUTER_API_KEY, GEMINI_API_KEY, OLLAMA_BASE_URL) instead of only OpenAI/Anthropic. That lets test_evals_finding_enricher run in non-OpenAI/Anthropic live-LLM setups that were previously skipped.

Comment on lines +24 to +35
async def run_agent(
spec: AgentEvalSpec,
finding: dict[str, Any],
*,
env: dict[str, str] | None = None,
model_id: str | None = None,
model: Model | None = None,
prior_context: dict[str, dict[str, Any]] | None = None,
) -> Any:
"""Run *spec*'s agent over a single eval case and return its output object.

Provide ``model`` directly (CI lane: a ``FunctionModel``/``TestModel``) or

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

run_agent duplicates the agent setup/run plumbing from run_no_tools_agent, should we extract a shared helper so prompt/deps/agent.run changes stay in sync?

Severity

Want Baz to fix this for you? Activate Fixer

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Commit 02411fa addressed this comment by extracting the shared setup/run plumbing into _run, so model construction, WorkspaceDeps, prompt rendering, and agent.run live in one place. run_agent now delegates to that helper, which keeps future prompt/deps/run changes in sync.

Comment thread backend/cliff/evals/adapter.py Outdated
Comment on lines +42 to +45
deps = WorkspaceDeps(
workspace_id="eval",
workspace_dir="/tmp/cliff-eval",
finding=finding,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

run_agent hardcodes WorkspaceDeps(workspace_id="eval", workspace_dir="/tmp/cliff-eval") instead of going through the workspace layer under data/workspaces/ — should we route eval runs there so file-backed state stays isolated per workspace?

Severity

Want Baz to fix this for you? Activate Fixer You can also update your AI coding guidelines based on this comment by apply pr to [branch name]

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
backend/cliff/evals/adapter.py around lines 42-45 inside run_agent, the WorkspaceDeps is
constructed with workspace_id="eval" and workspace_dir="/tmp/cliff-eval" which violates
CLAUDE.md’s workspace isolation rule and can cause file-backed agent/tool state to
bleed across eval cases. Refactor run_agent to allocate a unique workspace via the
workspace layer (under data/workspaces/<id>/) for each eval run/case—either by calling
the existing workspace creator from backend/cliff/workspace/ or by introducing a small
helper that returns an isolated workspace_id/workspace_dir. Then pass the allocated
workspace_id/workspace_dir into WorkspaceDeps so each case runs in its own directory.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/cliff/evals/adapter.py`:
- Around line 40-41: The code currently sets resolved_model = model if model is
not None else build_model(env or {}, model_id) and thus ignores
spec.default_model; update the resolution logic so that when model is None you
prefer spec.default_model if present (or fall back to build_model with
model_id), e.g. check spec.default_model before calling build_model; modify the
resolved_model assignment in adapter.py (the resolved_model, build_model,
model_id and spec objects) to: if model is not None use model, else if
spec.default_model is not None use spec.default_model, else call build_model(env
or {}, model_id).
- Around line 24-32: The run_agent entrypoint uses broad Any types; replace raw
dict/Any usage with explicit Pydantic models and strict typing: create a
Pydantic model (e.g., AgentFinding) for the finding parameter and a model (e.g.,
AgentResult) for the return value, update the run_agent signature to accept
AgentEvalSpec, finding: AgentFinding | dict and return AgentResult (or
AgentResult | dict for transitional compatibility), and change
prior_context/type hints to a typed mapping or Pydantic model instead of
dict[str, dict[str, Any]]; inside run_agent, parse incoming dicts with
AgentFinding.parse_obj/AgentResult.parse_obj to validate, update callers to
pass/consume the models (or rely on the parse logic), and add/adjust tests to
assert model validation and typed outputs.
- Around line 49-50: The adapter currently returns the raw result.output from
agent.run (called with build_user_prompt(deps) and deps=deps) without checking
it against spec.output_type; update the code in adapter.py to validate
result.output against deps.spec.output_type (or spec.output_type available on
deps) after agent.run completes, converting/coercing if possible and raising a
clear error (or returning a standardized ValidationError object) when the type
mismatches; ensure this validation is applied before returning from the function
so evaluators never receive unvalidated objects.

In `@backend/cliff/evals/cases.py`:
- Around line 36-51: The load_cases function accepts tier: str | None but should
be narrowed to the same literal domain as EvalCase.tier to prevent silent empty
filters; change the tier parameter type on load_cases to the Literal union used
by EvalCase.tier (or reference the EvalCase.tier type/alias if one exists) so
the signature becomes e.g. tier: Literal["ci","live"] | None (or the EvalCase
tier alias) and keep the existing filtering logic (cases = [c for c in cases if
c.tier == tier]) unchanged.

In `@backend/cliff/evals/evaluators.py`:
- Line 31: The current regex
re.compile(r"\b[a-z0-9_-]+\.[a-z0-9_-]+\.[a-z0-9_.-]+\b") is too broad and
matches semantic versions like "2.17.1"; update that pattern in evaluators.py to
exclude purely numeric dotted sequences by adding a negative lookahead that
rejects all-numeric dot-separated tokens (i.e., ensure at least one segment
contains a letter or underscore), so scanner jargon detection no longer flags
semantic versions while still matching true dotted rule paths.

In `@backend/cliff/evals/registry.py`:
- Around line 27-47: Convert the dataclasses BudgetSpec and AgentEvalSpec into
Pydantic models: replace `@dataclass` with subclasses of pydantic.BaseModel, use
explicit typing (Optional[float], Optional[int], Optional[str],
Callable[[Model], Agent], Type[BaseModel], FrozenSet[str]) for fields like
max_usd, max_tokens, max_duration_s, build_agent, output_type,
supported_assertions, budget, default_model, live_only, eval_frozen; set
immutable behavior via Config (allow_mutation = False) or frozen=True, and use
pydantic.Field to provide defaults (None/False) where needed so
validation/serialization matches the rest of the backend schema layer.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 3fdfa9c6-32ac-4b1b-91e4-90f745656c45

📥 Commits

Reviewing files that changed from the base of the PR and between d5bcc04 and 6653101.

📒 Files selected for processing (9)
  • backend/cliff/evals/__init__.py
  • backend/cliff/evals/adapter.py
  • backend/cliff/evals/cases.py
  • backend/cliff/evals/evaluators.py
  • backend/cliff/evals/registry.py
  • backend/tests/agents/conftest.py
  • backend/tests/agents/eval/finding_enricher.jsonl
  • backend/tests/agents/test_evals_finding_enricher.py
  • backend/tests/agents/test_evals_harness.py

Comment thread backend/cliff/evals/adapter.py Outdated
Comment thread backend/cliff/evals/adapter.py Outdated
Comment thread backend/cliff/evals/adapter.py Outdated
Comment thread backend/cliff/evals/cases.py Outdated
Comment thread backend/cliff/evals/evaluators.py Outdated
Comment thread backend/cliff/evals/registry.py Outdated
galanko and others added 2 commits June 9, 2026 11:50
Prep for the ADR-0050 hybrid (harness public, data private):

- load_cases reads CLIFF_EVAL_DATASET_DIR (default = the public synthetic
  sample under tests/agents/eval/); the private cliff-os/eval project
  overrides it to point at the real/confidential golden sets, which never
  enter this public repo.
- Extract the enricher run loop into cliff/evals/runners.run_enricher_eval
  (+ EvalRunResult) so both the in-repo live test AND the private cliff-os
  runner drive the same scorer over their own data.
- README in tests/agents/eval/ documents the public-sample/private-data split.

Verified: CI harness 9/9; live eval passes against gpt-4o-mini on the default
sample AND with CLIFF_EVAL_DATASET_DIR pointed at an override dir (the bridge).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The agent-eval hybrid (ADR-0050) needs the private cliff-os/eval project to
depend on the cliff backend to import the real agents. That requires cliff to
be a buildable/installable package — it had no [build-system]. Add hatchling
targeting the cliff package.

No change to the in-place run flow (uv sync / uv run unchanged); `uv build`
now produces cliff-<ver>-py3-none-any.whl. CI's install-smoke matrix validates
the packaging on this PR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
backend/cliff/evals/cases.py (1)

46-46: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Narrow tier parameter type to match EvalCase.tier domain.

The tier parameter accepts str | None, while EvalCase.tier at Line 39 is constrained to Literal["ci", "live"]. This type mismatch allows invalid tier values (e.g., tier="foo") to pass type checking but fail silently at Line 61's filter, returning an empty list.

As per coding guidelines, backend/cliff code should use strict type hints.

🔧 Proposed fix
-def load_cases(agent: str, *, tier: str | None = None) -> list[EvalCase]:
+def load_cases(agent: str, *, tier: Literal["ci", "live"] | None = None) -> list[EvalCase]:
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/cliff/evals/cases.py` at line 46, The load_cases function accepts
tier: str | None which is too loose compared to EvalCase.tier (Literal["ci",
"live"]); change the parameter type to match EvalCase.tier (e.g.,
Optional[Literal["ci", "live"]]) and update any imports as needed so callers and
static checkers only allow "ci" or "live"; locate the load_cases function and
the filter that checks case.tier and replace the str|None annotation with the
Literal-based type so invalid tiers cannot be passed.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@backend/cliff/evals/cases.py`:
- Line 46: The load_cases function accepts tier: str | None which is too loose
compared to EvalCase.tier (Literal["ci", "live"]); change the parameter type to
match EvalCase.tier (e.g., Optional[Literal["ci", "live"]]) and update any
imports as needed so callers and static checkers only allow "ci" or "live";
locate the load_cases function and the filter that checks case.tier and replace
the str|None annotation with the Literal-based type so invalid tiers cannot be
passed.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 1f6fbe8d-2101-49c2-91e5-a93d179cd99c

📥 Commits

Reviewing files that changed from the base of the PR and between 6653101 and 56d5bdb.

📒 Files selected for processing (5)
  • backend/cliff/evals/__init__.py
  • backend/cliff/evals/cases.py
  • backend/cliff/evals/runners.py
  • backend/tests/agents/eval/README.md
  • backend/tests/agents/test_evals_finding_enricher.py

Comment thread backend/cliff/evals/cases.py Outdated
Comment on lines +29 to +30
override = os.environ.get("CLIFF_EVAL_DATASET_DIR")
return Path(override) if override else _SAMPLE_DIR

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CLIFF_EVAL_DATASET_DIR is used verbatim, so relative overrides can resolve against the job cwd and miss load_cases(); should we reject non-absolute values or anchor them to a known base path?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
backend/cliff/evals/cases.py around lines 24-30, in the dataset_dir() function, the
CLIFF_EVAL_DATASET_DIR environment variable is returned as a raw Path(override), so
relative overrides depend on the current working directory and can point to the wrong
dataset. Refactor dataset_dir() to either reject non-absolute overrides with a clear
error message or resolve relative overrides against a known base path (e.g., the repo
root/tests/agents/eval parent), and ensure the final path exists before returning. This
will make load_cases() (lines ~47-48) reliably locate <agent>.jsonl from the intended
dataset directory.

Comment on lines +96 to +98
result.graded_rates = {
metric: (sum(vals) / len(vals) if vals else 1.0)
for metric, vals in graded.items()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

run_enricher_eval treats empty cases as 1.0, so zero-case evals can print PASS via EvalRunResult.passed; should we fail fast or force passed=False when n_cases == 0?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
backend/cliff/evals/runners.py around lines 56-100, inside run_enricher_eval and the
graded_rates computation (lines ~96-99), stop defaulting metric rates to 1.0 when cases
is empty. Either fail fast by raising an exception when n_cases == 0, or explicitly
force EvalRunResult.passed to return False when n_cases == 0 (and make graded_rates
reflect that, e.g., 0.0). Update the logic so an eval with zero loaded cases cannot
print PASS or satisfy any live-test assertions without actually running metrics.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Commit af806fc addressed this comment by adding an explicit if not cases guard that raises a ValueError before scoring starts. That prevents zero-case evals from falling through to the default 1.0 graded rates and printing PASS.

…ation

Closes the "declared but not enforced" budget gap (ADR-0050 §4) and calibrates
gating against a production-representative run.

- run_agent_measured returns token usage + wall-clock; the runner enforces the
  registry BudgetSpec: per-case max_tokens/max_duration_s (hard) + max_usd
  (best-effort via pricing.py), and per-run max_run_tokens/max_run_usd (the
  runaway-bill stop). A breach fails the eval. The report shows total tokens +
  $ estimate.
- reference_liveness is now ADVISORY (reported, non-gating): production strips
  dead links before the user sees them, so a dead-link guess is a model-quality
  signal, not user harm. Structural fabrication stays the hard gate.

Verified against the production model (claude-haiku-4-5): PASS, ~11k tok /
~$0.022, gating metrics clear the floor; reference_liveness tracked advisory.
Notable finding the eval surfaced: haiku guesses dead doc URLs more than
gpt-4o-mini (advisory ~33-50%) — a real, customer-relevant model quirk.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread backend/cliff/evals/pricing.py Outdated
Comment on lines +24 to +26
def _match(model_id: str | None) -> str | None:
m = (model_id or "").lower()
return next((k for k in _PRICE_USD_PER_MTOK if k in m), None)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_match() does a first-substring search on model_id, so openai/gpt-5-mini collapses to gpt-5 and gets priced against the wrong tier; could we match the model segment exactly or prefer the longest key?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
backend/cliff/evals/pricing.py around lines 24-26, the `_match(model_id)` function
currently matches by checking whether each price key is a substring of the full
lowercased model id, which incorrectly collapses `openai/gpt-5-mini` into the `gpt-5`
tier. Refactor `_match` to prefer the most specific/longest key and avoid mid-token
substring matches by matching against model segments (e.g., split on `/` and treat
`-`-separated tokens as boundaries) or otherwise requiring boundary/segment-level
equality. Ensure that `gpt-5-mini` matches the `gpt-5-mini` key only (if present) and
does not fall back to `gpt-5` when the id contains `gpt-5-mini`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Commit af806fc addressed this comment by changing _match() to collect all substring candidates and choose the longest key. That prevents shorter tiers like gpt-5 from winning when a more specific key such as gpt-5-mini is present.

- evaluators: dotted-rule jargon regex no longer matches semantic versions
  like "2.17.1" (CodeRabbit major); drop the redundant http branch in
  assess_references (Baz).
- cases: `expected` is a typed `Expected` model (extra=forbid) so malformed
  rows fail in load_cases instead of slipping past the evaluators (Baz); `tier`
  narrowed to the `Tier` literal (CodeRabbit); `CLIFF_EVAL_DATASET_DIR` is
  resolved to an absolute path so a relative override can't depend on cwd (Baz).
- adapter: honor `spec.default_model` when no model_id (CodeRabbit major);
  validate output is `spec.output_type` (CodeRabbit); narrow `Any` -> typed
  Finding/BaseModel boundary (CodeRabbit major).
- registry: BudgetSpec + AgentEvalSpec are Pydantic models, frozen (CodeRabbit).
- runners: 0 cases now raises instead of silently reporting PASS (Baz); pass
  the typed expected to evaluators via as_dict().
- pricing: longest-key match so e.g. gpt-4o-mini isn't priced as gpt-4o (Baz).
- conftest: widen the live-LLM key gate to all build_model providers
  (OpenRouter/Gemini/Ollama), not just OpenAI/Anthropic (Baz).

Declined: extracting a shared run_agent/run_no_tools_agent helper — they
return different shapes (raw output object vs post-processed dict) and the
shared plumbing is ~2 lines (simplicity rule).

Verified: ruff clean, CI harness 9/9, 104 deterministic agent tests, and the
live eval vs claude-haiku-4-5 PASS (the regex fix lifted no_jargon_title to
100% by dropping a semantic-version false positive).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@galanko

galanko commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator Author

Review addressed (commit af806fc)

All threads from Baz + CodeRabbit, fixed except one principled decline.

Fixed:

  • evaluators — dotted-rule jargon regex no longer matches semantic versions (2.17.1) (CodeRabbit major); dropped the redundant http is not None branch (Baz).
  • casesexpected is now a typed Expected model (extra=forbid) so malformed rows fail in load_cases (Baz); tier narrowed to the Tier literal (CodeRabbit); CLIFF_EVAL_DATASET_DIR resolved to absolute so a relative override can't depend on cwd (Baz).
  • adapter — honors spec.default_model when model_id is omitted (CodeRabbit major); validates output is spec.output_type (CodeRabbit); narrowed Any → typed Finding/BaseModel boundary (CodeRabbit major).
  • registryBudgetSpec + AgentEvalSpec are now frozen Pydantic models (CodeRabbit).
  • runners — 0 cases raises instead of silently reporting PASS (Baz); typed expected passed via as_dict().
  • pricing — longest-key match, so e.g. gpt-4o-mini isn't priced as gpt-4o (Baz). Note: pricing stays approximate/advisory; token caps are the hard limit.
  • conftest — live-LLM key gate widened to all build_model providers (OpenRouter/Gemini/Ollama), not just OpenAI/Anthropic (Baz).

Declined (with reason): extracting a shared run_agent / run_no_tools_agent helper. They return different shapes — the eval needs the raw validated output object (to measure pre-production-cleaning behavior + token usage), while run_no_tools_agent returns the post-processed model_dump() dict for the sidebar; the shared plumbing is ~2 lines, so a helper would couple production to the eval path to DRY almost nothing (project simplicity rule).

Verified: ruff clean · CI harness 9/9 · 104 deterministic agent tests · live eval vs claude-haiku-4-5 PASS — the regex fix actually lifted no_jargon_title 83% → 100% by dropping a semantic-version false positive.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
backend/cliff/evals/evaluators.py (1)

119-124: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Normalize nullable CVSS range bounds before numeric comparison.

Line 124 can raise at runtime when expected carries explicit null for cvss_min/cvss_max (currently allowed by the typed case model), because the code compares None with float.

Suggested fix
     if "cvss_min" in expected or "cvss_max" in expected:
-        lo = expected.get("cvss_min", 0.0)
-        hi = expected.get("cvss_max", 10.0)
+        lo_raw = expected.get("cvss_min")
+        hi_raw = expected.get("cvss_max")
+        lo = 0.0 if lo_raw is None else float(lo_raw)
+        hi = 10.0 if hi_raw is None else float(hi_raw)
+        if lo > hi:
+            return False, f"invalid cvss range [{lo},{hi}]"
         if got is None:
             return False, f"cvss expected in [{lo},{hi}], got null"
         return (lo <= float(got) <= hi), f"cvss {got} in [{lo},{hi}]"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/cliff/evals/evaluators.py` around lines 119 - 124, The CVSS range
check in evaluators.py can break when expected["cvss_min"] or
expected["cvss_max"] are explicitly null; update the block that handles
"cvss_min"/"cvss_max" to normalize nullable bounds before numeric comparison:
fetch values from expected with expected.get("cvss_min") and
expected.get("cvss_max"), treat None the same as the defaults (0.0 and 10.0)
before assigning to lo and hi, and ensure you convert lo and hi to floats only
after applying the defaults; keep the existing got None check and the return
shape used in this function (the block that currently uses lo =
expected.get("cvss_min", 0.0) / hi = expected.get("cvss_max", 10.0) and the
final comparison).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/cliff/evals/runners.py`:
- Around line 132-139: The cost check is using the raw argument model_id which
can be None, causing USD caps to be skipped; change calls to estimate_cost_usd
and the subsequent budget comparison to use the resolved/default model (e.g.,
resolved_model = model_id or spec.default_model) instead of model_id when
computing case_cost for functions like estimate_cost_usd(model_id,
run.input_tokens, run.output_tokens) and when checking budget.max_usd and
appending to result.budget_failures (also fix the same pattern around the later
block at the similar case handling near the 163-168 region); ensure you
reference resolved_model everywhere the pricing and cap comparison occur so None
never bypasses enforcement.

---

Outside diff comments:
In `@backend/cliff/evals/evaluators.py`:
- Around line 119-124: The CVSS range check in evaluators.py can break when
expected["cvss_min"] or expected["cvss_max"] are explicitly null; update the
block that handles "cvss_min"/"cvss_max" to normalize nullable bounds before
numeric comparison: fetch values from expected with expected.get("cvss_min") and
expected.get("cvss_max"), treat None the same as the defaults (0.0 and 10.0)
before assigning to lo and hi, and ensure you convert lo and hi to floats only
after applying the defaults; keep the existing got None check and the return
shape used in this function (the block that currently uses lo =
expected.get("cvss_min", 0.0) / hi = expected.get("cvss_max", 10.0) and the
final comparison).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 505dc90d-f340-418b-9b7b-46ec5483d18c

📥 Commits

Reviewing files that changed from the base of the PR and between 56d5bdb and af806fc.

⛔ Files ignored due to path filters (1)
  • backend/uv.lock is excluded by !**/*.lock
📒 Files selected for processing (9)
  • backend/cliff/evals/adapter.py
  • backend/cliff/evals/cases.py
  • backend/cliff/evals/evaluators.py
  • backend/cliff/evals/pricing.py
  • backend/cliff/evals/registry.py
  • backend/cliff/evals/runners.py
  • backend/pyproject.toml
  • backend/tests/agents/conftest.py
  • backend/tests/agents/test_evals_harness.py

Comment thread backend/cliff/evals/runners.py Outdated
galanko and others added 2 commits June 9, 2026 18:04
Correctness:
- conftest: the widened live-LLM gate (prev commit) un-skipped live tests for
  Ollama/OpenRouter/Gemini-only hosts, but eval_model() only maps Anthropic
  else gpt-4o-mini -> those hosts hard-errored instead of skipping. FIX: gate +
  model selection now share one policy (cliff.evals.models.eval_runnable /
  select_eval_model) so they can't diverge; an Ollama-only host with no override
  skips cleanly; OpenRouter/Gemini auto-select a runnable model.
- evaluators: the dotted-rule jargon regex flagged legit titles (domains like
  www.example.com, frameworks like asp.net.core.mvc). FIX: require a hyphen/
  underscore in the run (real scanner rule ids have one) so only rule paths match.

Altitude / cleanup:
- Move model+env selection out of tests/eval_utils into the public
  cliff/evals/models.py so the private cliff-os/eval runner shares it instead of
  re-implementing (kills the cross-repo drift the helper exists to prevent).
- registry: drop dead config (live_only, eval_frozen — nothing read them);
  soften the supported_assertions docstring (the runner doesn't yet validate
  per-case assertions — cases don't declare them).
- adapter: rename the AgentRun measurement struct -> MeasuredRun (it shadowed
  the domain cliff.models.AgentRun used in ~20 files).
- cases: load_cases hints at CLIFF_EVAL_DATASET_DIR when the (wheel-excluded)
  default sample dir is missing.
- pricing: docstring is honest that an unpriced sub-model falls back to its
  nearest priced sibling (over-estimate; token cap is the hard limit).

Verified: ruff clean, CI harness 9/9, 104 deterministic agent tests, live eval
vs claude-haiku-4-5 PASS (no_jargon_title 100% with the regex fix).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
estimate_cost_usd was called with the raw model_id, which can be None when the
caller relies on the adapter's default — silently skipping the USD caps. Price
against `model_id or spec.default_model` (what the adapter actually runs), and
set finding_enricher's default_model to the production default
(anthropic/claude-haiku-4-5) so the fallback + pricing are both real.

The other open threads were already addressed in prior commits (redundant http
branch removed, conftest gate now shares eval_runnable across all providers,
empty-cases now raises) and one is a standing decline (run_agent vs
run_no_tools_agent — different return shapes, ~2 shared lines).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@galanko

galanko commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator Author

Latest review pass (commit 5cf5f9a)

Fixed (new, CodeRabbit major): USD budget was priced against the raw model_id, which can be None when the caller relies on the adapter's default → the $ caps were silently skipped. Now priced against model_id or spec.default_model (what the adapter actually runs), and finding_enricher.default_model is set to the production default anthropic/claude-haiku-4-5 so both the fallback and pricing are real.

Already addressed in earlier commits (stale threads):

  • evaluators.py redundant if http is not None branch → removed (now a single clean_references(refs, http=http)).
  • conftest.py provider gate → now eval_runnable() from cliff.evals.models, shared with the runner and covering all build_model providers (OpenRouter/Gemini/…), not just OpenAI/Anthropic.
  • runners.py empty cases → now raises instead of reporting PASS.

Standing decline: run_agent vs run_no_tools_agent dedup — they return different shapes (the eval needs the raw validated output object + token usage; run_no_tools_agent returns the post-processed model_dump() dict for the sidebar), sharing ~2 lines, so a helper would couple production to the eval path to DRY almost nothing.

Verified: ruff clean · CI harness 9/9 · 104 deterministic agent tests · live eval vs haiku PASS.

Comment on lines +45 to +46
if override := env.get("CLIFF_EVAL_MODEL"):
return override

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CLIFF_EVAL_MODEL makes eval_runnable() return true before checking provider env, so live tests can un-skip and then fail in build_model(); should we validate the selected model against available creds first?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
backend/cliff/evals/models.py around lines 37-57, the select_eval_model and
eval_runnable logic currently returns CLIFF_EVAL_MODEL unconditionally (lines 45-46), so
eval_runnable becomes true even when no provider credentials are available. Refactor so
that when CLIFF_EVAL_MODEL is set, you validate that the overridden model’s provider
is actually configured in the harvested env (required *_API_KEY and any needed
*_BASE_URL), and only return the override (and thus True for eval_runnable) if the
provider is runnable; otherwise return None/False. If there is an existing
provider/model resolution function used by backend/cliff/agents/runtime/provider.py
(build_model or equivalent), call it (or factor out a shared “resolve model if
configured” helper) so the skip gate matches what tests will execute and never
diverges again.

@galanko galanko merged commit 7e0b8c3 into main Jun 9, 2026
8 checks passed
@galanko galanko deleted the feat/eval-finding-enricher branch June 9, 2026 15:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant