blob: 20eb766862a41f49f607f1626f3f425caee8eed1 [file]
#!/usr/bin/env python3
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
# /// script
# requires-python = ">=3.9"
# ///
"""Airflow skill-eval harness.
Test whether AGENTS.md guidance affects agent decisions by comparing
arms with and without the guidance. Each arm is a git worktree of the
real repo — the agent sees actual source files.
Usage:
prek run run-skill-eval --hook-stage manual --all-files
Env knobs: MODEL, SKILL_NAME, EVAL_REPEAT, EVAL_FULL (baseline arm).
Promptfoo flags like --filter* are argv-only — wire them as fixed entry
args on a hook variant when needed.
Authentication: Claude Code session (claude /login) or ANTHROPIC_API_KEY.
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
PROMPTFOO_VERSION = "0.121.17"
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
SCRIPT_DIR = Path(__file__).resolve().parent
AGENTS_SRC = REPO_ROOT / "AGENTS.md"
CASES_DIR = SCRIPT_DIR / "cases"
HASH_FILE = SCRIPT_DIR / "last-eval-hash.txt"
# Tool state lives under .build (repo-scoped, established cleanup culture);
# per-run reports go to files/ per the AGENTS.md output convention.
PROMPTFOO_STATE_DIR = REPO_ROOT / ".build" / "promptfoo"
RESULTS_FILE = REPO_ROOT / "files" / "skill-evals" / "results.json"
# Shared with the check-eval-hash prek hook so recorded and verified hashes can't drift.
sys.path.insert(0, str(REPO_ROOT / "scripts" / "ci" / "prek"))
from check_eval_hash import compute_guidance_hash, write_recorded_hash # noqa: E402
OUTPUT_FORMAT = {
"type": "json_schema",
"schema": {
"type": "object",
"required": ["should_create", "rationale"],
"additionalProperties": False,
"properties": {
"should_create": {"type": "boolean"},
"type": {
"type": "string",
"enum": ["bugfix", "feature", "improvement", "doc", "misc", "significant"],
},
"rationale": {"type": "string"},
},
},
}
def run(cmd: list[str], **kwargs) -> subprocess.CompletedProcess[str]:
return subprocess.run(cmd, capture_output=True, text=True, check=False, **kwargs)
def find_sdk_modules() -> Path:
"""Locate the node_modules dir (in the prek env) that contains the Claude Agent SDK.
promptfoo resolves the SDK from the eval config's directory, not from its
own install tree — the caller symlinks this dir next to the config.
"""
promptfoo_bin = shutil.which("promptfoo")
if promptfoo_bin:
# PROMPTFOO_DISABLE_UPDATE avoids an outdated version banner on stdout when a newer
# promptfoo is published upstream, which would otherwise break this exact match.
version = run(
["promptfoo", "--version"], env={**os.environ, "PROMPTFOO_DISABLE_UPDATE": "1"}
).stdout.strip()
if version == PROMPTFOO_VERSION:
pf_pkg = Path(promptfoo_bin).resolve()
while pf_pkg.name != "promptfoo" or not (pf_pkg / "package.json").is_file():
if pf_pkg.parent == pf_pkg:
break
pf_pkg = pf_pkg.parent
for candidate in (pf_pkg / "node_modules", pf_pkg.parent):
if (candidate / "@anthropic-ai" / "claude-agent-sdk").is_dir():
return candidate
print(
"Error: promptfoo with the Claude Agent SDK not found. Run the eval via:\n"
" prek run run-skill-eval --hook-stage manual --all-files",
file=sys.stderr,
)
sys.exit(1)
def check_claude_md_symlink(base_branch: str) -> None:
"""Verify CLAUDE.md is a symlink to AGENTS.md, in the working tree and on the base branch.
The Claude Agent SDK reads CLAUDE.md. Swapping AGENTS.md between arms only
affects the agent while CLAUDE.md resolves to it — if CLAUDE.md ever becomes
a regular file, every arm would read identical guidance and the eval would
silently measure nothing.
"""
link = REPO_ROOT / "CLAUDE.md"
working_ok = link.is_symlink() and os.readlink(link) == "AGENTS.md"
tree_entry = run(["git", "-C", str(REPO_ROOT), "ls-tree", base_branch, "CLAUDE.md"]).stdout
target = run(["git", "-C", str(REPO_ROOT), "show", f"{base_branch}:CLAUDE.md"]).stdout
branch_ok = tree_entry.startswith("120000") and target == "AGENTS.md"
if not (working_ok and branch_ok):
print(
"Error: CLAUDE.md must be a symlink to AGENTS.md (in the working tree"
f" and on '{base_branch}'). The eval swaps AGENTS.md between arms and"
" relies on the agent reading it through that symlink.",
file=sys.stderr,
)
sys.exit(1)
def resolve_base_branch() -> str:
result = run(["git", "-C", str(REPO_ROOT), "rev-parse", "--verify", "main"])
if result.returncode == 0:
return "main"
branch = run(["git", "-C", str(REPO_ROOT), "rev-parse", "--abbrev-ref", "HEAD"]).stdout.strip()
print(f"Warning: 'main' not found, using current branch '{branch}' as base")
return branch
def git_show_file(base_branch: str, path: str, dest: Path) -> bool:
result = run(["git", "-C", str(REPO_ROOT), "show", f"{base_branch}:{path}"])
if result.returncode != 0:
return False
dest.write_text(result.stdout)
return True
def create_worktree(
work_dir: Path,
name: str,
base_branch: str,
agents_file: Path | None,
worktrees: list[Path],
skill_name: str | None = None,
skill_file: Path | None = None,
) -> Path:
"""Create a git worktree arm with the specified AGENTS.md and optional SKILL.md."""
wt_dir = work_dir / name
result = run(
[
"git",
"-C",
str(REPO_ROOT),
"worktree",
"add",
"--quiet",
"--detach",
str(wt_dir),
base_branch,
]
)
if result.returncode != 0:
print(
f"Error: 'git worktree add' failed for arm '{name}':\n{result.stderr.strip()}",
file=sys.stderr,
)
sys.exit(1)
# Register before mutating the checkout so cleanup covers this worktree
# even if a later step in this function fails.
worktrees.append(wt_dir)
if agents_file is None:
# Baseline arm: remove all guidance. CLAUDE.md is a symlink to
# AGENTS.md — drop it too so the SDK finds no dangling link.
(wt_dir / "AGENTS.md").unlink(missing_ok=True)
(wt_dir / "CLAUDE.md").unlink(missing_ok=True)
else:
shutil.copy2(agents_file, wt_dir / "AGENTS.md")
if skill_name and skill_file:
skill_dir = wt_dir / ".agents" / "skills" / skill_name
skill_dir.mkdir(parents=True, exist_ok=True)
shutil.copy2(skill_file, skill_dir / "SKILL.md")
return wt_dir
def remove_worktree(wt_dir: Path) -> None:
run(["git", "-C", str(REPO_ROOT), "worktree", "remove", "--force", str(wt_dir)])
def build_provider(label: str, working_dir: Path, model: str, skill_name: str | None = None) -> dict:
config: dict = {
"model": model,
"apiKeyRequired": False,
"setting_sources": ["project"],
"append_allowed_tools": ["Read", "Grep", "Glob"],
"working_dir": str(working_dir),
"output_format": OUTPUT_FORMAT,
}
if skill_name:
config["skills"] = [skill_name]
return {"id": "anthropic:claude-agent-sdk", "label": label, "config": config}
def count_provider_errors(results_file: Path) -> int:
"""Count results whose provider call errored (as opposed to failing an assertion)."""
try:
results = json.loads(results_file.read_text())["results"]["results"]
except (OSError, KeyError, ValueError):
return 0
return sum(1 for r in results if (r.get("response") or {}).get("error"))
def main() -> int:
sdk_modules = find_sdk_modules()
model = os.environ.get("MODEL", "claude-sonnet-4-6")
skill_name = os.environ.get("SKILL_NAME")
skill_src = None
if skill_name:
skill_src = REPO_ROOT / ".agents" / "skills" / skill_name / "SKILL.md"
if not skill_src.is_file():
print(f"Error: {skill_src} not found", file=sys.stderr)
return 1
# Parse flags — argv (hook entry args) plus single-value env knobs,
# since `prek run` can't forward arguments. No filter knob on purpose:
# a lingering export must not be able to change what the proof claims.
full_mode = "--full" in sys.argv or os.environ.get("EVAL_FULL", "").lower() in ("1", "true")
promptfoo_args = [a for a in sys.argv[1:] if a != "--full"]
repeat = os.environ.get("EVAL_REPEAT")
if repeat:
if not repeat.isdigit() or int(repeat) < 1:
print(f"Error: EVAL_REPEAT must be a positive integer, got {repeat!r}", file=sys.stderr)
return 1
promptfoo_args += ["--repeat", repeat]
if full_mode and skill_name:
print(
"Error: EVAL_FULL/--full cannot be combined with SKILL_NAME — the baseline"
" arm has no skill, so the 'skill-used' assertion would always fail on it.",
file=sys.stderr,
)
return 1
base_branch = resolve_base_branch()
check_claude_md_symlink(base_branch)
# Hash before building arms so edits made mid-run aren't recorded as tested.
guidance_hash = compute_guidance_hash(AGENTS_SRC, CASES_DIR)
# Temp dir for config and worktrees
work_dir = Path(tempfile.mkdtemp())
worktrees: list[Path] = []
try:
# promptfoo resolves the Claude Agent SDK from the config directory
(work_dir / "node_modules").symlink_to(sdk_modules)
# Extract main-branch AGENTS.md
main_agents = work_dir / "main-agents.md"
if not git_show_file(base_branch, "AGENTS.md", main_agents):
shutil.copy2(AGENTS_SRC, main_agents)
print(f"Warning: AGENTS.md not found on {base_branch} — main arm uses working tree copy")
main_skill = None
if skill_name and skill_src:
main_skill = work_dir / "main-skill.md"
skill_git_path = f".agents/skills/{skill_name}/SKILL.md"
if not git_show_file(base_branch, skill_git_path, main_skill):
shutil.copy2(skill_src, main_skill)
print(f"Warning: SKILL.md not found on {base_branch} — main arm uses working tree copy")
# Detect changes to decide which arms to build
agents_changed = run(["diff", "-q", str(main_agents), str(AGENTS_SRC)]).returncode != 0
skill_changed = False
if skill_name and main_skill and skill_src:
skill_changed = run(["diff", "-q", str(main_skill), str(skill_src)]).returncode != 0
need_working = agents_changed or skill_changed
# Assemble arms. Prune first to self-heal stale worktree registrations
# left behind by a previously interrupted run.
print("Assembling arms (git worktrees) ...")
run(["git", "-C", str(REPO_ROOT), "worktree", "prune"])
arm_main = create_worktree(
work_dir, "main", base_branch, main_agents, worktrees, skill_name, main_skill
)
arm_working = None
if need_working:
arm_working = create_worktree(
work_dir, "working", base_branch, AGENTS_SRC, worktrees, skill_name, skill_src
)
else:
print(" AGENTS.md unchanged — skipping working arm")
arm_baseline = None
if full_mode:
arm_baseline = create_worktree(work_dir, "baseline", base_branch, None, worktrees)
# Generate config (JSON — valid promptfoo config, keeps the script stdlib-only)
providers = [build_provider("main", arm_main, model, skill_name)]
if arm_working:
providers.append(build_provider("working", arm_working, model, skill_name))
if full_mode and arm_baseline:
providers.append(build_provider("baseline", arm_baseline, model))
default_test: dict = {"options": {"disableVarExpansion": True}}
if skill_name:
default_test["assert"] = [{"type": "skill-used", "value": skill_name}]
config = {
"prompts": ["{{request}}"],
"providers": providers,
"defaultTest": default_test,
"tests": f"file://{SCRIPT_DIR}/cases/*.yaml",
}
config_path = work_dir / "promptfooconfig.json"
config_path.write_text(json.dumps(config, indent=2))
# Report
if skill_name:
print(f"Mode: {len(providers)} arms, skill '{skill_name}', model: {model}")
else:
print(f"Mode: {len(providers)} arms, AGENTS.md only, model: {model}")
print()
print(f"Changes detected (vs {base_branch}):")
print(f" AGENTS.md — {'modified' if agents_changed else 'unchanged'}")
if skill_name:
print(f" SKILL.md — {'modified' if skill_changed else 'unchanged'} ({skill_name})")
print()
# Run promptfoo — state under .build, per-run report under files/
PROMPTFOO_STATE_DIR.mkdir(parents=True, exist_ok=True)
RESULTS_FILE.parent.mkdir(parents=True, exist_ok=True)
result = subprocess.run(
["promptfoo", "eval", "-c", str(config_path), "--output", str(RESULTS_FILE), *promptfoo_args],
check=False,
env={**os.environ, "PROMPTFOO_CONFIG_DIR": str(PROMPTFOO_STATE_DIR)},
)
# 0 = all passed, 100 = some assertions failed — both mean the eval
# completed and the results were seen, which is what the hash proves.
# Don't record partial runs (--filter* covers a subset of cases) or
# runs with provider errors (nothing was actually evaluated).
partial_run = any(arg.startswith("--filter") for arg in promptfoo_args)
provider_errors = count_provider_errors(RESULTS_FILE)
if result.returncode in (0, 100) and not partial_run and not provider_errors:
write_recorded_hash(guidance_hash, HASH_FILE)
print()
print(f"Recorded eval run in {HASH_FILE.relative_to(REPO_ROOT)} — commit it with your change.")
elif partial_run:
print()
print("Partial run (--filter*) — hash not recorded; run the full case set to update it.")
elif provider_errors:
print()
print(f"{provider_errors} provider error(s) — hash not recorded; fix the setup and rerun.")
print()
print(f"Results report: {RESULTS_FILE.relative_to(REPO_ROOT)}")
print(
f"View results: PROMPTFOO_CONFIG_DIR={PROMPTFOO_STATE_DIR.relative_to(REPO_ROOT)}"
f" npx promptfoo@{PROMPTFOO_VERSION} view"
)
return result.returncode
finally:
for wt in worktrees:
remove_worktree(wt)
shutil.rmtree(work_dir, ignore_errors=True)
if __name__ == "__main__":
raise SystemExit(main())