#!/usr/bin/env python3 # /// script # requires-python = ">=3.11" # /// """Trigger evals: does a skill's description fire on each near-miss query? A trigger query is a should/should-not user message that shares keywords with the skill, so the description has to discriminate. For each query the runner stages a synthetic skill with the description under test where the harness reads skills, sends the query through the harness in a clean room, and checks whether the skill loaded. Each query runs several times (--runs-per-query) so the rate is stable, not a coin flip. Detection is a canary, the same on every harness. The synthetic skill's body asks the model to begin its reply with a token unique to that attempt; the token appearing in what the harness printed is the load. The skill's name and description never contain the token, so a harness that lists its discovered skills at startup cannot fake a hit. A query whose attempts did not all complete (command failed, timed out) is unmeasured, never passed: a should-not query with no completed attempts would otherwise pass at a rate of zero. Each attempt keeps prompt.txt, transcript.jsonl (what the harness printed), stderr.txt, timing.json and cwd/ (the workspace after the run, staged skill included) under /queries/qNNN-rN/. Harness and clean room are as in run_evals.py. Usage: uv run run_triggers.py --skill-path SKILL_DIR --queries QUERIES.json --output-dir DIR [--project-root DIR] [--harness HARNESS.json] [--runs-per-query N] [--threshold 0.5] [--timeout SECS] [--workers N] [--quiet] QUERIES.json is a list of {"query": "...", "should_trigger": true|false}. Exit 0 when every query was measured, 1 when any attempt failed or the command was not found, 2 on a usage error, 3 when no harness is recorded. A query failing its threshold is a result, not an error. """ from __future__ import annotations import argparse import json import re import sys import uuid from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path from eval_common import ( DEFAULT_SKILL_DIR, account_transcript, find_project_root, make_run_dir, read_json, resolve_harness, run_in_clean_room, utc_now_iso, write_json, ) CANARY_PREFIX = "TRIGGER-LOADED-" def unquote_yaml(value: str) -> str: """A YAML scalar's text: single quotes doubled inside, double quotes with backslash escapes.""" value = value.strip() if len(value) >= 2 and value[0] == value[-1] == "'": return value[1:-1].replace("''", "'") if len(value) >= 2 and value[0] == value[-1] == '"': return value[1:-1].replace('\\"', '"').replace("\\n", "\n").replace("\\\\", "\\") return value def parse_skill_md(skill_path: Path) -> tuple[str, str]: """Return (name, description) from SKILL.md frontmatter, quotes and block indicators removed.""" text = (skill_path / "SKILL.md").read_text(encoding="utf-8") m = re.match(r"^---\s*\n(.*?)\n---\s*\n", text, re.DOTALL) if not m: raise ValueError(f"SKILL.md at {skill_path} is missing frontmatter") fields: dict[str, list[str]] = {} current = None for line in m.group(1).splitlines(): if line[:1] not in (" ", "\t") and ":" in line: key, _, value = line.partition(":") current = key.strip() fields[current] = [] if value.strip() in ("|", ">", "|-", ">-") else [value.strip()] elif current and line.strip(): fields[current].append(line.strip()) name = unquote_yaml(" ".join(fields.get("name", []))) if not name: raise ValueError(f"SKILL.md at {skill_path} has no name") return name, unquote_yaml(" ".join(fields.get("description", []))) # --- synthetic skill and detection ------------------------------------------- def write_synthetic_skill(skills_dir: Path, skill_name: str, description: str, token: str) -> str: """Write a skill the harness can discover, carrying the description and the canary. Returns its name.""" clean_name = f"{skill_name}-trig-{token[len(CANARY_PREFIX) :]}" root = skills_dir / clean_name root.mkdir(parents=True, exist_ok=True) indented = "\n ".join(description.split("\n")) (root / "SKILL.md").write_text( f"---\nname: {clean_name}\ndescription: |\n {indented}\n---\n\n" f"# {skill_name}\n\nThis skill handles: {description}\n\n" f"Begin your reply with the exact token `{token}`, then continue.\n", encoding="utf-8", ) return clean_name def detect_load(output: str, token: str) -> bool: """Did the synthetic skill load? Its canary token in the output says so.""" return token in output # --- per-attempt execution ---------------------------------------------------- def run_query_once( query: str, skill_name: str, description: str, harness: dict, attempt_dir: Path, timeout: int ) -> tuple[bool | None, str]: """One attempt: (loaded, "") when it completed, (None, why) when it did not.""" token = CANARY_PREFIX + uuid.uuid4().hex[:8] def stage(cwd: Path) -> None: write_synthetic_skill(cwd / harness.get("skill_dir", DEFAULT_SKILL_DIR), skill_name, description, token) run = run_in_clean_room(harness, attempt_dir, query, timeout, stage) accounting = account_transcript(run["stdout"]) loaded = detect_load(run["stdout"], token) if run["status"] == "ok" else None write_json( attempt_dir / "timing.json", { "status": run["status"], "elapsed_s": run["elapsed_s"], "return_code": run["return_code"], "loaded": loaded, "total_tokens": accounting["total_tokens"], "tokens_reported": accounting["tokens_reported"], "captured_at": utc_now_iso(), }, ) if run["status"] != "ok": return None, f"{run['status']}: {run['stderr'][-500:].strip()}" return loaded, "" # --- main ------------------------------------------------------------------- def main(argv: list[str] | None = None) -> int: p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) p.add_argument("--skill-path", required=True, type=Path) p.add_argument("--queries", required=True, type=Path) p.add_argument("--output-dir", required=True, type=Path) p.add_argument( "--project-root", type=Path, default=None, help="holds _bmad/; found from the skill path when omitted" ) p.add_argument("--harness", type=Path, default=None, help="harness JSON for a project without BMad") p.add_argument("--runs-per-query", type=int, default=3) p.add_argument("--threshold", type=float, default=0.5) p.add_argument("--timeout", type=int, default=180) p.add_argument("--workers", type=int, default=4) p.add_argument("--quiet", action="store_true") args = p.parse_args(argv) skill_path = args.skill_path.resolve() queries_file = args.queries.resolve() if not queries_file.is_file(): print(f"queries file not found: {queries_file}", file=sys.stderr) return 2 skill_name, description = parse_skill_md(skill_path) queries = read_json(queries_file) if not isinstance(queries, list): print("queries file must be a JSON list", file=sys.stderr) return 2 project_root = args.project_root.resolve() if args.project_root else find_project_root(skill_path) try: harness, harness_note = resolve_harness(project_root, args.harness) except (ValueError, json.JSONDecodeError) as e: print(f"harness invalid: {e}", file=sys.stderr) return 2 run_id, run_dir = make_run_dir(args.output_dir, f"{skill_name}-triggers") (run_dir / "queries").mkdir(parents=True, exist_ok=True) write_json( run_dir / "run.json", { "run_id": run_id, "skill_name": skill_name, "description": description, "harness": harness_note, "command": (harness or {}).get("command"), "started_at": utc_now_iso(), "query_count": len(queries), "runs_per_query": args.runs_per_query, "threshold": args.threshold, }, ) if harness is None: if not args.quiet: print(f"[run_triggers] no harness ({harness_note}); staging only", file=sys.stderr) output = { "run_id": run_id, "completed_at": utc_now_iso(), "skill_name": skill_name, "description": description, "status": "skipped", "reason": "no harness recorded", "results": [], "summary": {"total": len(queries), "passed": 0, "failed": 0, "unmeasured": len(queries)}, } write_json(run_dir / "triggers-result.json", output) print(json.dumps(output, indent=2)) return 3 def run_one(idx: int, q: dict, run_idx: int) -> tuple[int, bool | None, str]: attempt_dir = run_dir / "queries" / f"q{idx:03d}-r{run_idx}" try: loaded, error = run_query_once(q["query"], skill_name, description, harness, attempt_dir, args.timeout) except (OSError, ValueError) as e: loaded, error = None, str(e) return idx, loaded, error attempts: dict[int, list[bool]] = {idx: [] for idx in range(len(queries))} errors: dict[int, list[str]] = {idx: [] for idx in range(len(queries))} if not args.quiet: print(f"[run_triggers] harness: {' '.join(harness['command'])}", file=sys.stderr) print(f"[run_triggers] {len(queries)} queries x {args.runs_per_query} runs", file=sys.stderr) with ThreadPoolExecutor(max_workers=max(1, args.workers)) as pool: futures = [ pool.submit(run_one, idx, q, run_idx) for idx, q in enumerate(queries) for run_idx in range(args.runs_per_query) ] for fut in as_completed(futures): idx, loaded, error = fut.result() if loaded is None: errors[idx].append(error) if not args.quiet: print(f" attempt failed for query {idx}: {error}", file=sys.stderr) else: attempts[idx].append(loaded) results = [] for idx, q in enumerate(queries): runs = attempts[idx] measured = len(runs) == args.runs_per_query rate = (sum(runs) / len(runs)) if runs else 0.0 should = bool(q.get("should_trigger", True)) passed = ((rate >= args.threshold) if should else (rate < args.threshold)) if measured else None results.append( { "query": q["query"], "should_trigger": should, "trigger_rate": round(rate, 3), "triggers": int(sum(runs)), "runs": len(runs), "errors": errors[idx], "pass": passed, } ) unmeasured = sum(1 for r in results if r["pass"] is None) output = { "run_id": run_id, "completed_at": utc_now_iso(), "skill_name": skill_name, "description": description, "harness": harness_note, "results": results, "summary": { "total": len(results), "passed": sum(1 for r in results if r["pass"] is True), "failed": sum(1 for r in results if r["pass"] is False), "unmeasured": unmeasured, }, } write_json(run_dir / "triggers-result.json", output) print(json.dumps(output, indent=2)) return 1 if unmeasured else 0 if __name__ == "__main__": sys.exit(main())