run_triggers.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462
  1. #!/usr/bin/env python3
  2. # /// script
  3. # requires-python = ">=3.9"
  4. # ///
  5. """Trigger evals: does a skill's description fire on each near-miss query?
  6. A trigger query is a should/should-not user message that shares keywords with
  7. the skill so the description has to discriminate. For each query the runner
  8. stages a synthetic skill where the runtime looks for skills, sends the query
  9. through the adapter, and detects whether the skill loaded. Each query runs
  10. several times (runs-per-query) so the trigger rate is stable, not a coin flip.
  11. Detection lives behind the adapter. "Did the skill load" is a runtime-specific
  12. signal, so the adapter declares how skills are staged and how a load shows up in
  13. the transcript. The adapter config (see references/platform-adapter.md) adds two
  14. trigger-specific keys to the core ones:
  15. invocation : argv template; "{prompt}" (or "{query}") is replaced with the
  16. query text, "{cwd}" with the staging dir.
  17. auth_env : auth env-var name, forwarded only when set non-empty on the
  18. host. No model id.
  19. skill_dir : path under the staging cwd where a skill is discovered, e.g.
  20. ".claude/skills". The runner writes the synthetic skill there.
  21. load_signal: which tool_use events count as a load:
  22. {"skill_tool": "Skill", "read_tool": "Read"} (defaults)
  23. A load is a tool_use of skill_tool whose input names the
  24. synthetic skill, or a read_tool whose file_path falls inside
  25. the synthetic skill's directory. Whole-transcript substring
  26. matching is NOT supported: the runtime's init event lists
  27. every discovered skill, so a substring match reports 100%
  28. trigger rate regardless of the description.
  29. Each query runs in a built-from-scratch environment (PATH, fresh empty HOME,
  30. CLAUDE_CONFIG_DIR inside it, auth var only when set, adapter env_passthrough
  31. keys) so the host's installed skills, memory, and config cannot bias firing.
  32. If no adapter is configured the runner degrades gracefully: it stages each query
  33. and records "skipped: no runtime adapter configured" rather than crashing.
  34. Usage:
  35. python3 run_triggers.py \\
  36. --skill-path SKILL_DIR \\
  37. --queries QUERIES.json \\
  38. --output-dir DIR \\
  39. [--adapter ADAPTER.json] \\
  40. [--runs-per-query N] [--threshold 0.5] [--timeout SECS] \\
  41. [--workers N] [--quiet]
  42. QUERIES.json is a list of {"query": "...", "should_trigger": true|false}.
  43. SKILL_DIR contains the SKILL.md whose name + description are under test; the
  44. description is what the synthetic skill advertises.
  45. """
  46. from __future__ import annotations
  47. import argparse
  48. import json
  49. import os
  50. import re
  51. import shutil
  52. import subprocess
  53. import sys
  54. import uuid
  55. from concurrent.futures import ThreadPoolExecutor, as_completed
  56. from datetime import datetime, timezone
  57. from pathlib import Path
  58. # --- self-contained helpers -------------------------------------------------
  59. def utc_now_iso() -> str:
  60. return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
  61. def new_run_id(label: str) -> str:
  62. return f"{datetime.now().strftime('%Y%m%d-%H%M%S')}-{label}"
  63. def write_json(path: Path, data: object) -> None:
  64. path.parent.mkdir(parents=True, exist_ok=True)
  65. path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
  66. def read_json(path: Path) -> object:
  67. return json.loads(path.read_text(encoding="utf-8"))
  68. def parse_skill_md(skill_path: Path) -> tuple[str, str]:
  69. """Return (name, description) from SKILL.md frontmatter."""
  70. text = (skill_path / "SKILL.md").read_text(encoding="utf-8")
  71. m = re.match(r"^---\s*\n(.*?)\n---\s*\n", text, re.DOTALL)
  72. if not m:
  73. raise ValueError(f"SKILL.md at {skill_path} is missing frontmatter")
  74. frontmatter = m.group(1)
  75. name = None
  76. desc_lines: list[str] = []
  77. in_desc = False
  78. for line in frontmatter.splitlines():
  79. if line.startswith("name:"):
  80. name = line.split(":", 1)[1].strip()
  81. in_desc = False
  82. elif line.startswith("description:"):
  83. value = line.split(":", 1)[1].strip()
  84. if value in ("|", ">"):
  85. in_desc = True
  86. else:
  87. desc_lines = [value]
  88. in_desc = False
  89. elif in_desc and line.startswith((" ", "\t")):
  90. desc_lines.append(line.strip())
  91. elif in_desc:
  92. in_desc = False
  93. if not name:
  94. raise ValueError(f"SKILL.md at {skill_path} has no name")
  95. return name, " ".join(desc_lines).strip()
  96. # --- adapter ----------------------------------------------------------------
  97. def find_adapter(explicit: Path | None, queries_file: Path) -> Path | None:
  98. if explicit is not None:
  99. return explicit if explicit.is_file() else None
  100. env_path = os.environ.get("BMAD_EVAL_ADAPTER")
  101. if env_path and Path(env_path).is_file():
  102. return Path(env_path)
  103. for candidate in (
  104. queries_file.parent / "adapter.json",
  105. queries_file.parent / ".bmad-eval-adapter.json",
  106. ):
  107. if candidate.is_file():
  108. return candidate
  109. return None
  110. def load_adapter(path: Path) -> dict:
  111. cfg = read_json(path)
  112. if not isinstance(cfg, dict) or "invocation" not in cfg:
  113. raise ValueError(f"adapter config missing 'invocation': {path}")
  114. return cfg
  115. def build_argv(invocation: list, query: str, cwd: str) -> list[str]:
  116. out: list[str] = []
  117. for tok in invocation:
  118. tok = (str(tok).replace("{prompt}", query)
  119. .replace("{query}", query)
  120. .replace("{cwd}", cwd))
  121. out.append(tok)
  122. return out
  123. def build_case_env(adapter: dict | None, home_dir: Path,
  124. host_env: dict) -> dict[str, str]:
  125. """Build the subprocess environment from scratch — never from os.environ.
  126. Inheriting the host env would leak shell config, tokens, and runtime
  127. state into the clean room. The env holds exactly: PATH, a fresh HOME,
  128. CLAUDE_CONFIG_DIR inside it, the adapter's auth var ONLY when set
  129. non-empty in the host (an empty-string auth var breaks the runtime's own
  130. credential fallback), and any adapter env_passthrough keys present in
  131. the host env.
  132. """
  133. adapter = adapter or {}
  134. env = {
  135. "PATH": host_env.get("PATH", ""),
  136. "HOME": str(home_dir),
  137. "CLAUDE_CONFIG_DIR": str(home_dir / ".claude"),
  138. }
  139. auth_env = adapter.get("auth_env")
  140. if auth_env:
  141. val = host_env.get(str(auth_env))
  142. if val:
  143. env[str(auth_env)] = val
  144. for key in adapter.get("env_passthrough") or []:
  145. val = host_env.get(str(key))
  146. if val is not None:
  147. env[str(key)] = val
  148. return env
  149. # --- synthetic skill staging ------------------------------------------------
  150. def write_synthetic_skill(skills_dir: Path, skill_name: str,
  151. description: str, unique: str) -> str:
  152. """Write a synthetic skill the runtime can discover. Returns its unique name.
  153. A unique suffix lets the detector tell this synthetic skill apart from any
  154. real skill of the same display name.
  155. """
  156. clean_name = f"{skill_name}-trig-{unique}"
  157. root = skills_dir / clean_name
  158. root.mkdir(parents=True, exist_ok=True)
  159. indented = "\n ".join(description.split("\n"))
  160. (root / "SKILL.md").write_text(
  161. f"---\n"
  162. f"name: {clean_name}\n"
  163. f"description: |\n"
  164. f" {indented}\n"
  165. f"---\n\n"
  166. f"# {skill_name}\n\n"
  167. f"This skill handles: {description}\n",
  168. encoding="utf-8",
  169. )
  170. return clean_name
  171. # --- load detection (behind the adapter) ------------------------------------
  172. def validate_load_signal(load_signal: dict | None) -> None:
  173. """Reject substring-style load signals before any query runs."""
  174. if (load_signal or {}).get("type") == "string":
  175. raise ValueError(
  176. "load_signal type 'string' is not supported: the runtime's init "
  177. "event lists every discovered skill, so a whole-transcript "
  178. "substring match reports 100% trigger rate regardless of the "
  179. "description. Use tool-call detection "
  180. '({"skill_tool": ..., "read_tool": ...}).'
  181. )
  182. def detect_load(transcript_text: str, load_signal: dict, clean_name: str) -> bool:
  183. """Did the synthetic skill load? Only tool_use events count.
  184. The init event of a stream-json transcript lists every discovered skill
  185. by name, so the name appearing somewhere in the transcript proves
  186. nothing. A load is a skill-invocation tool call naming the synthetic
  187. skill, or a read of a file inside the synthetic skill's directory (its
  188. SKILL.md) — the two ways a runtime actually pulls a skill into context.
  189. """
  190. validate_load_signal(load_signal)
  191. sig = load_signal or {}
  192. skill_tool = sig.get("skill_tool", "Skill")
  193. read_tool = sig.get("read_tool", "Read")
  194. for raw in transcript_text.splitlines():
  195. raw = raw.strip()
  196. if not raw:
  197. continue
  198. try:
  199. evt = json.loads(raw)
  200. except json.JSONDecodeError:
  201. continue
  202. if not isinstance(evt, dict) or evt.get("type") != "assistant":
  203. continue
  204. msg = evt.get("message", {})
  205. content = msg.get("content", []) if isinstance(msg, dict) else []
  206. for item in content:
  207. if not isinstance(item, dict) or item.get("type") != "tool_use":
  208. continue
  209. name = item.get("name")
  210. inp = item.get("input", {})
  211. if not isinstance(inp, dict):
  212. inp = {}
  213. if name == skill_tool and clean_name in json.dumps(inp):
  214. return True
  215. if name == read_tool and clean_name in str(inp.get("file_path", "")):
  216. return True
  217. return False
  218. # --- per-query execution ----------------------------------------------------
  219. def run_query_once(query: str, skill_name: str, description: str,
  220. adapter: dict, stage_dir: Path, timeout: int) -> bool:
  221. skill_subdir = adapter.get("skill_dir", ".claude/skills")
  222. skills_dir = stage_dir / skill_subdir
  223. skills_dir.mkdir(parents=True, exist_ok=True)
  224. unique = uuid.uuid4().hex[:8]
  225. clean_name = write_synthetic_skill(skills_dir, skill_name, description, unique)
  226. home_dir = stage_dir / ".home"
  227. (home_dir / ".claude").mkdir(parents=True, exist_ok=True)
  228. env = build_case_env(adapter, home_dir, dict(os.environ))
  229. argv = build_argv(adapter["invocation"], query, str(stage_dir))
  230. try:
  231. proc = subprocess.run(
  232. argv,
  233. stdout=subprocess.PIPE,
  234. stderr=subprocess.DEVNULL,
  235. cwd=str(stage_dir),
  236. env=env,
  237. timeout=timeout,
  238. )
  239. captured = proc.stdout or b""
  240. except subprocess.TimeoutExpired as e:
  241. captured = e.stdout or b""
  242. except FileNotFoundError:
  243. # invocation command absent; treat as undetected and let caller note it
  244. raise
  245. transcript_cfg = adapter.get("transcript", {"format": "stdout-jsonl"})
  246. if transcript_cfg.get("format") == "file":
  247. f = stage_dir / transcript_cfg.get("path", "transcript.jsonl")
  248. text = f.read_text(encoding="utf-8", errors="replace") if f.is_file() else ""
  249. else:
  250. text = captured.decode("utf-8", errors="replace")
  251. return detect_load(text, adapter.get("load_signal", {}), clean_name)
  252. # --- main -------------------------------------------------------------------
  253. def main(argv: list[str] | None = None) -> int:
  254. p = argparse.ArgumentParser(
  255. description=__doc__,
  256. formatter_class=argparse.RawDescriptionHelpFormatter,
  257. )
  258. p.add_argument("--skill-path", required=True, type=Path)
  259. p.add_argument("--queries", required=True, type=Path)
  260. p.add_argument("--output-dir", required=True, type=Path)
  261. p.add_argument("--adapter", type=Path, default=None)
  262. p.add_argument("--runs-per-query", type=int, default=3)
  263. p.add_argument("--threshold", type=float, default=0.5)
  264. p.add_argument("--timeout", type=int, default=60)
  265. p.add_argument("--workers", type=int, default=4)
  266. p.add_argument("--quiet", action="store_true")
  267. args = p.parse_args(argv)
  268. skill_path = args.skill_path.resolve()
  269. queries_file = args.queries.resolve()
  270. if not queries_file.is_file():
  271. print(f"queries file not found: {queries_file}", file=sys.stderr)
  272. return 2
  273. skill_name, description = parse_skill_md(skill_path)
  274. queries = read_json(queries_file)
  275. if not isinstance(queries, list):
  276. print("queries file must be a JSON list", file=sys.stderr)
  277. return 2
  278. adapter_path = find_adapter(args.adapter, queries_file)
  279. adapter: dict | None = None
  280. adapter_note = "none"
  281. if adapter_path is not None:
  282. try:
  283. adapter = load_adapter(adapter_path)
  284. validate_load_signal(adapter.get("load_signal"))
  285. adapter_note = str(adapter_path)
  286. except Exception as e:
  287. print(f"adapter config invalid ({e}); degrading to skip-only",
  288. file=sys.stderr)
  289. adapter = None
  290. adapter_note = f"invalid: {e}"
  291. run_id = new_run_id(f"{skill_name}-triggers")
  292. run_dir = (args.output_dir / run_id).resolve()
  293. (run_dir / "queries").mkdir(parents=True, exist_ok=True)
  294. write_json(run_dir / "run.json", {
  295. "run_id": run_id,
  296. "skill_name": skill_name,
  297. "description": description,
  298. "adapter": adapter_note,
  299. "started_at": utc_now_iso(),
  300. "query_count": len(queries),
  301. "runs_per_query": args.runs_per_query,
  302. "threshold": args.threshold,
  303. })
  304. if adapter is None:
  305. if not args.quiet:
  306. print("[run_triggers] no runtime adapter configured; staging only "
  307. "(no crash).", file=sys.stderr)
  308. output = {
  309. "run_id": run_id,
  310. "completed_at": utc_now_iso(),
  311. "skill_name": skill_name,
  312. "description": description,
  313. "status": "skipped",
  314. "reason": "no runtime adapter configured",
  315. "results": [],
  316. "summary": {"total": len(queries), "passed": 0, "failed": 0,
  317. "skipped": len(queries)},
  318. }
  319. write_json(run_dir / "triggers-result.json", output)
  320. print(json.dumps(output, indent=2))
  321. return 0
  322. adapter_missing = {"flag": False}
  323. def run_one(idx: int, q: dict, run_idx: int) -> tuple[int, bool]:
  324. stage = run_dir / "queries" / f"q{idx:03d}-r{run_idx}"
  325. stage.mkdir(parents=True, exist_ok=True)
  326. try:
  327. triggered = run_query_once(
  328. q["query"], skill_name, description, adapter, stage, args.timeout)
  329. except FileNotFoundError:
  330. adapter_missing["flag"] = True
  331. triggered = False
  332. finally:
  333. shutil.rmtree(stage / adapter.get("skill_dir", ".claude/skills").split("/")[0],
  334. ignore_errors=True)
  335. return idx, triggered
  336. per_query: dict[int, list[bool]] = {}
  337. if not args.quiet:
  338. print(f"[run_triggers] {len(queries)} queries x {args.runs_per_query} "
  339. f"runs", file=sys.stderr)
  340. with ThreadPoolExecutor(max_workers=max(1, args.workers)) as pool:
  341. futures = []
  342. for idx, q in enumerate(queries):
  343. for run_idx in range(args.runs_per_query):
  344. futures.append(pool.submit(run_one, idx, q, run_idx))
  345. for fut in as_completed(futures):
  346. try:
  347. idx, triggered = fut.result()
  348. except Exception as e:
  349. print(f"Warning: query run failed: {e}", file=sys.stderr)
  350. continue
  351. per_query.setdefault(idx, []).append(triggered)
  352. if adapter_missing["flag"]:
  353. output = {
  354. "run_id": run_id,
  355. "completed_at": utc_now_iso(),
  356. "skill_name": skill_name,
  357. "status": "adapter-missing",
  358. "reason": "adapter invocation command not found on PATH",
  359. "results": [],
  360. "summary": {"total": len(queries), "passed": 0, "failed": 0},
  361. }
  362. write_json(run_dir / "triggers-result.json", output)
  363. print(json.dumps(output, indent=2))
  364. return 0
  365. results = []
  366. for idx, q in enumerate(queries):
  367. runs = per_query.get(idx, [])
  368. rate = (sum(runs) / len(runs)) if runs else 0.0
  369. should = bool(q.get("should_trigger", True))
  370. passed = (rate >= args.threshold) if should else (rate < args.threshold)
  371. results.append({
  372. "query": q["query"],
  373. "should_trigger": should,
  374. "trigger_rate": round(rate, 3),
  375. "triggers": int(sum(runs)),
  376. "runs": len(runs),
  377. "pass": passed,
  378. })
  379. output = {
  380. "run_id": run_id,
  381. "completed_at": utc_now_iso(),
  382. "skill_name": skill_name,
  383. "description": description,
  384. "adapter": adapter_note,
  385. "results": results,
  386. "summary": {
  387. "total": len(results),
  388. "passed": sum(1 for r in results if r["pass"]),
  389. "failed": sum(1 for r in results if not r["pass"]),
  390. },
  391. }
  392. write_json(run_dir / "triggers-result.json", output)
  393. print(json.dumps(output, indent=2))
  394. return 0
  395. if __name__ == "__main__":
  396. sys.exit(main())