ma-agents 3.13.2 → 3.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/agents.js +7 -0
- package/lib/bmad-cache/bmb/.claude-plugin/marketplace.json +3 -3
- package/lib/bmad-cache/bmb/_git_preserved/index +0 -0
- package/lib/bmad-cache/bmb/_git_preserved/objects/pack/pack-b388526edf307cf56f8e65673466cff074105abf.idx +0 -0
- package/lib/bmad-cache/bmb/_git_preserved/objects/pack/{pack-8f8b045fef5af6911495cf3b2a89f1ed75e120f7.pack → pack-b388526edf307cf56f8e65673466cff074105abf.pack} +0 -0
- package/lib/bmad-cache/bmb/_git_preserved/objects/pack/pack-b388526edf307cf56f8e65673466cff074105abf.rev +0 -0
- package/lib/bmad-cache/bmb/_git_preserved/packed-refs +1 -1
- package/lib/bmad-cache/bmb/_git_preserved/refs/heads/main +1 -1
- package/lib/bmad-cache/bmb/_git_preserved/shallow +1 -1
- package/lib/bmad-cache/bmb/package-lock.json +2 -2
- package/lib/bmad-cache/bmb/package.json +1 -1
- package/lib/bmad-cache/bmb/samples/bmad-agent-code-coach/scripts/wake.py +78 -0
- package/lib/bmad-cache/bmb/samples/bmad-agent-creative-muse/scripts/wake.py +78 -0
- package/lib/bmad-cache/bmb/samples/bmad-agent-sentinel/scripts/wake.py +78 -0
- package/lib/bmad-cache/bmb/skills/bmad-agent-builder/assets/customize-template.toml +3 -0
- package/lib/bmad-cache/bmb/skills/bmad-agent-builder/assets/init-sanctum-template.py +11 -5
- package/lib/bmad-cache/bmb/skills/bmad-agent-builder/assets/report-shell.html +1073 -0
- package/lib/bmad-cache/bmb/skills/bmad-agent-builder/assets/wake-template.py +78 -0
- package/lib/bmad-cache/bmb/skills/bmad-agent-builder/customize.toml +48 -0
- package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/count_tokens.py +78 -0
- package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/prepass.py +258 -0
- package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/process-template.py +20 -0
- package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/render_report.py +387 -0
- package/lib/bmad-cache/bmb/skills/bmad-eval-runner/assets/adapter-claude-code.json +9 -0
- package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/aggregate_benchmark.py +236 -0
- package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/memlog.py +197 -0
- package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/run_evals.py +513 -390
- package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/run_triggers.py +347 -251
- package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/tests/test_env_isolation.py +79 -0
- package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/tests/test_trigger_detection.py +123 -0
- package/lib/bmad-cache/bmb/skills/bmad-module-builder/scripts/tests/test-validate-module.py +82 -4
- package/lib/bmad-cache/bmb/skills/bmad-module-builder/scripts/validate-module.py +55 -11
- package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/assets/customize-template.toml +3 -5
- package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/assets/report-shell.html +860 -0
- package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/customize.toml +59 -0
- package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/count_tokens.py +77 -0
- package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/init_skill.py +132 -0
- package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/prepass-prompt-metrics.py +186 -221
- package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/quick_validate.py +117 -0
- package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/render_report.py +387 -0
- package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/tests/test_canon_sync.py +46 -0
- package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/tests/test_count_tokens.py +180 -0
- package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/tests/test_render_report.py +171 -0
- package/lib/bmad-cache/cache-manifest.json +4 -4
- package/lib/bmad-extension/.claude-plugin/marketplace.json.template +1 -1
- package/lib/bmad-extension-plugin/.claude-plugin/marketplace.json +2 -2
- package/lib/bmad.js +6 -1
- package/lib/templates/instruction-block-git.template.md +25 -25
- package/lib/templates/instruction-block-onprem.template.md +86 -86
- package/lib/templates/instruction-block-universal.template.md +29 -29
- package/package.json +3 -3
- package/lib/.bmad-extension-plugin.build-22612-1782454014750/.claude-plugin/marketplace.json +0 -109
- package/lib/.bmad-extension-plugin.build-22612-1782454014750/skills/module-help.csv +0 -62
- package/lib/.bmad-extension-plugin.build-22612-1782454014750/skills/module.yaml +0 -20
- package/lib/bmad-cache/bmb/_git_preserved/objects/pack/pack-8f8b045fef5af6911495cf3b2a89f1ed75e120f7.idx +0 -0
- package/lib/bmad-cache/bmb/_git_preserved/objects/pack/pack-8f8b045fef5af6911495cf3b2a89f1ed75e120f7.rev +0 -0
- package/lib/bmad-cache/bmb/skills/bmad-agent-builder/references/sample-init-sanctum.py +0 -274
- package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/generate-html-report.py +0 -534
- package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/prepass-execution-deps.py +0 -337
- package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/prepass-prompt-metrics.py +0 -425
- package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/prepass-sanctum-architecture.py +0 -385
- package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/prepass-structure-capabilities.py +0 -482
- package/lib/bmad-cache/bmb/skills/bmad-eval-runner/assets/Dockerfile +0 -29
- package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/docker_setup.py +0 -115
- package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/generate_report.py +0 -184
- package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/pty_runner.py +0 -171
- package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/utils.py +0 -260
- package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/extract-report-json.py +0 -287
- package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/generate-html-report.py +0 -588
- package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/prepass-execution-deps.py +0 -288
|
@@ -2,31 +2,83 @@
|
|
|
2
2
|
# /// script
|
|
3
3
|
# requires-python = ">=3.9"
|
|
4
4
|
# ///
|
|
5
|
-
"""Run
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
5
|
+
"""Run eval cases through the configured platform adapter.
|
|
6
|
+
|
|
7
|
+
A case is `input + rubric + optional state_prefix + optional files`. This
|
|
8
|
+
runner does the runtime-specific part of an eval: it stages the skill under
|
|
9
|
+
test and the case's fixture files into a clean working directory, builds the
|
|
10
|
+
prompt the adapter understands, runs it, and records the transcript plus
|
|
11
|
+
timing and token usage. Grading happens elsewhere; the grader subagent reads
|
|
12
|
+
the transcript and artifacts this runner leaves behind.
|
|
13
|
+
|
|
14
|
+
What this runner deliberately does NOT do:
|
|
15
|
+
- No Docker, no PTY, no keychain staging, no dual-isolation strategy.
|
|
16
|
+
- No hardcoded model. Everything runtime-specific comes from the adapter.
|
|
17
|
+
|
|
18
|
+
Modes (--mode) decide which configs each case runs under:
|
|
19
|
+
|
|
20
|
+
quality : one config, "skill" — the skill staged in the cwd.
|
|
21
|
+
baseline : two configs per case — "skill" (skill staged) and "bare"
|
|
22
|
+
(nothing staged), same input, so the bare-model floor is
|
|
23
|
+
measured under identical conditions.
|
|
24
|
+
variant : two configs — "skill" (--skill-path) and "variant"
|
|
25
|
+
(--variant-path, the stripped or prior-version skill).
|
|
26
|
+
|
|
27
|
+
Run layout: <run-dir>/<config>/<case-id>/ (plus /run-N/ when --runs > 1),
|
|
28
|
+
so `aggregate_benchmark.py --baseline <run-dir>/bare --variant
|
|
29
|
+
<run-dir>/skill` compares configs directly from the timing.json files.
|
|
30
|
+
|
|
31
|
+
Skill staging: the skill directory is copied (symlink where possible) into
|
|
32
|
+
<case-cwd>/<skill_dir>/<skill-name>/ before the adapter is invoked, where
|
|
33
|
+
skill_dir comes from the adapter (default ".claude/skills"). Without this
|
|
34
|
+
every config would measure the bare model.
|
|
35
|
+
|
|
36
|
+
Fixtures: each path in a case's `files` list is staged into the case cwd at
|
|
37
|
+
its own relative path. Sources resolve against --project-root, then the cases
|
|
38
|
+
file's directory, then as absolute paths.
|
|
39
|
+
|
|
40
|
+
Isolation: the subprocess env is built from scratch, never inherited. It
|
|
41
|
+
holds PATH, a fresh empty HOME at <case>/.home, CLAUDE_CONFIG_DIR inside
|
|
42
|
+
that HOME, the adapter's auth_env var ONLY if set non-empty in the host env
|
|
43
|
+
(setting it to "" would break the runtime's own credential fallback), and any
|
|
44
|
+
adapter `env_passthrough` keys present in the host env. Nothing else crosses.
|
|
45
|
+
|
|
46
|
+
The adapter config file (JSON) — schema and discovery rules in
|
|
47
|
+
references/platform-adapter.md, working example in
|
|
48
|
+
assets/adapter-claude-code.json:
|
|
49
|
+
|
|
50
|
+
invocation : argv template. "{prompt}" -> composed case prompt,
|
|
51
|
+
"{cwd}" -> clean working directory.
|
|
52
|
+
auth_env : env var name carrying auth (e.g. "ANTHROPIC_API_KEY").
|
|
53
|
+
transcript : {"format": "stdout-jsonl"} or
|
|
54
|
+
{"format": "file", "path": "transcript.jsonl"}.
|
|
55
|
+
skill_dir : where the runtime discovers skills under the cwd.
|
|
56
|
+
env_passthrough : optional list of extra host env vars to forward.
|
|
57
|
+
|
|
58
|
+
If no adapter config is found, the runner degrades gracefully: it stages every
|
|
59
|
+
case (clean cwd, skill, fixtures, prompt with state_prefix applied) and writes
|
|
60
|
+
a manifest, but records each result as "skipped: no runtime adapter
|
|
61
|
+
configured" instead of crashing. A human or a configured runtime can then
|
|
62
|
+
complete the run.
|
|
63
|
+
|
|
64
|
+
state_prefix handling: when a case carries a state_prefix, it is PREPENDED to
|
|
65
|
+
the input to place the skill mid-workflow in one shot. The composed prompt is
|
|
66
|
+
recorded so the grader sees exactly what ran.
|
|
21
67
|
|
|
22
68
|
Usage:
|
|
23
69
|
python3 run_evals.py \\
|
|
24
|
-
--
|
|
25
|
-
--
|
|
26
|
-
--
|
|
27
|
-
--
|
|
28
|
-
--
|
|
29
|
-
[--
|
|
70
|
+
--cases CASES.json \\
|
|
71
|
+
--skill-path SKILL_DIR \\
|
|
72
|
+
--output-dir DIR \\
|
|
73
|
+
[--mode quality|baseline|variant] \\
|
|
74
|
+
[--variant-path SKILL_DIR] \\
|
|
75
|
+
[--project-root DIR] \\
|
|
76
|
+
[--adapter ADAPTER.json] \\
|
|
77
|
+
[--case-ids A1,B3] [--runs N] [--timeout SECS] [--workers N] [--quiet]
|
|
78
|
+
|
|
79
|
+
CASES.json is either a list of cases or {"cases": [...]}. Each case:
|
|
80
|
+
{"id": "...", "input": "...", "rubric": [...],
|
|
81
|
+
"state_prefix": "..."?, "files": ["..."]?}
|
|
30
82
|
"""
|
|
31
83
|
|
|
32
84
|
from __future__ import annotations
|
|
@@ -38,448 +90,519 @@ import shutil
|
|
|
38
90
|
import subprocess
|
|
39
91
|
import sys
|
|
40
92
|
import time
|
|
93
|
+
from collections.abc import Mapping
|
|
41
94
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
95
|
+
from datetime import datetime, timezone
|
|
42
96
|
from pathlib import Path
|
|
43
97
|
|
|
44
|
-
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
45
|
-
sys.path.insert(0, str(SCRIPT_DIR))
|
|
46
|
-
|
|
47
|
-
from utils import ( # noqa: E402
|
|
48
|
-
apply_setup_overlay,
|
|
49
|
-
discover_setup_dirs,
|
|
50
|
-
new_run_id,
|
|
51
|
-
parse_skill_md,
|
|
52
|
-
read_json,
|
|
53
|
-
read_macos_keychain_credentials,
|
|
54
|
-
stage_credentials,
|
|
55
|
-
utc_now_iso,
|
|
56
|
-
write_json,
|
|
57
|
-
)
|
|
58
|
-
|
|
59
|
-
DOCKER_IMAGE = "bmad-eval-runner:latest"
|
|
60
|
-
_KEYCHAIN_CREDS: str | None = read_macos_keychain_credentials()
|
|
61
|
-
RSYNC_EXCLUDES = (
|
|
62
|
-
".git", ".bare", "node_modules", ".venv", "__pycache__",
|
|
63
|
-
".pytest_cache", ".next", "dist", "build", ".cache",
|
|
64
|
-
".DS_Store", "*.pyc",
|
|
65
|
-
)
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
def stage_workspace_local(
|
|
69
|
-
workspace: Path,
|
|
70
|
-
project_root: Path,
|
|
71
|
-
skill_path: Path,
|
|
72
|
-
fixtures: list[tuple[Path, str]],
|
|
73
|
-
setup_dirs: list[Path] | None = None,
|
|
74
|
-
) -> Path:
|
|
75
|
-
"""Build a clean local workspace. Returns the project root inside workspace."""
|
|
76
|
-
workspace.mkdir(parents=True, exist_ok=True)
|
|
77
|
-
project_dest = workspace / "project"
|
|
78
|
-
home_dir = workspace / ".home"
|
|
79
|
-
(home_dir / ".claude").mkdir(parents=True, exist_ok=True)
|
|
80
98
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
99
|
+
# --- small self-contained helpers (no Docker/keychain imports) -------------
|
|
100
|
+
|
|
101
|
+
def utc_now_iso() -> str:
|
|
102
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def new_run_id(label: str) -> str:
|
|
106
|
+
return f"{datetime.now().strftime('%Y%m%d-%H%M%S')}-{label}"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def write_json(path: Path, data: object) -> None:
|
|
110
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
111
|
+
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def read_json(path: Path) -> object:
|
|
115
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# --- adapter ----------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
def find_adapter(explicit: Path | None, cases_file: Path) -> Path | None:
|
|
121
|
+
"""Locate the adapter config. Returns None when none is configured."""
|
|
122
|
+
if explicit is not None:
|
|
123
|
+
return explicit if explicit.is_file() else None
|
|
124
|
+
env_path = os.environ.get("BMAD_EVAL_ADAPTER")
|
|
125
|
+
if env_path and Path(env_path).is_file():
|
|
126
|
+
return Path(env_path)
|
|
127
|
+
for candidate in (
|
|
128
|
+
cases_file.parent / "adapter.json",
|
|
129
|
+
cases_file.parent / ".bmad-eval-adapter.json",
|
|
130
|
+
):
|
|
131
|
+
if candidate.is_file():
|
|
132
|
+
return candidate
|
|
133
|
+
return None
|
|
84
134
|
|
|
85
|
-
if shutil.which("rsync"):
|
|
86
|
-
subprocess.run(
|
|
87
|
-
["rsync", "-a", *excludes, f"{project_root}/", f"{project_dest}/"],
|
|
88
|
-
check=True,
|
|
89
|
-
)
|
|
90
|
-
else:
|
|
91
|
-
shutil.copytree(project_root, project_dest, dirs_exist_ok=True,
|
|
92
|
-
ignore=shutil.ignore_patterns(*RSYNC_EXCLUDES))
|
|
93
135
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
136
|
+
def load_adapter(path: Path) -> dict:
|
|
137
|
+
cfg = read_json(path)
|
|
138
|
+
if not isinstance(cfg, dict):
|
|
139
|
+
raise ValueError(f"adapter config must be a JSON object: {path}")
|
|
140
|
+
if "invocation" not in cfg or not isinstance(cfg["invocation"], list):
|
|
141
|
+
raise ValueError("adapter config missing 'invocation' argv list")
|
|
142
|
+
return cfg
|
|
97
143
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
144
|
+
|
|
145
|
+
def build_argv(invocation: list, prompt: str, cwd: str) -> list[str]:
|
|
146
|
+
argv: list[str] = []
|
|
147
|
+
for tok in invocation:
|
|
148
|
+
tok = str(tok)
|
|
149
|
+
tok = (tok.replace("{prompt}", prompt)
|
|
150
|
+
.replace("{query}", prompt)
|
|
151
|
+
.replace("{cwd}", cwd))
|
|
152
|
+
argv.append(tok)
|
|
153
|
+
return argv
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def build_case_env(adapter: Mapping | None, home_dir: Path,
|
|
157
|
+
host_env: Mapping[str, str]) -> dict[str, str]:
|
|
158
|
+
"""Build the subprocess environment from scratch — never from os.environ.
|
|
159
|
+
|
|
160
|
+
Inheriting the host env would leak shell config, tokens, and runtime
|
|
161
|
+
state into the clean room. The env holds exactly: PATH, a fresh HOME,
|
|
162
|
+
CLAUDE_CONFIG_DIR inside it, the adapter's auth var ONLY when set
|
|
163
|
+
non-empty in the host (an empty-string auth var breaks the runtime's own
|
|
164
|
+
credential fallback), and any adapter env_passthrough keys present in
|
|
165
|
+
the host env.
|
|
166
|
+
"""
|
|
167
|
+
adapter = adapter or {}
|
|
168
|
+
env = {
|
|
169
|
+
"PATH": host_env.get("PATH", ""),
|
|
170
|
+
"HOME": str(home_dir),
|
|
171
|
+
"CLAUDE_CONFIG_DIR": str(home_dir / ".claude"),
|
|
172
|
+
}
|
|
173
|
+
auth_env = adapter.get("auth_env")
|
|
174
|
+
if auth_env:
|
|
175
|
+
val = host_env.get(str(auth_env))
|
|
176
|
+
if val:
|
|
177
|
+
env[str(auth_env)] = val
|
|
178
|
+
for key in adapter.get("env_passthrough") or []:
|
|
179
|
+
val = host_env.get(str(key))
|
|
180
|
+
if val is not None:
|
|
181
|
+
env[str(key)] = val
|
|
182
|
+
return env
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# --- staging: skill under test + fixtures ------------------------------------
|
|
186
|
+
|
|
187
|
+
def stage_skill(skill_path: Path, cwd: Path, skills_subdir: str) -> Path:
|
|
188
|
+
"""Place the skill where the runtime discovers skills inside the cwd.
|
|
189
|
+
|
|
190
|
+
Symlink when possible (cheap, and the skill is read-only to the run);
|
|
191
|
+
copy as the fallback.
|
|
192
|
+
"""
|
|
193
|
+
dest_root = cwd / skills_subdir
|
|
194
|
+
dest_root.mkdir(parents=True, exist_ok=True)
|
|
195
|
+
dest = dest_root / skill_path.name
|
|
196
|
+
if not dest.exists():
|
|
102
197
|
try:
|
|
103
|
-
os.symlink(skill_path,
|
|
198
|
+
os.symlink(skill_path, dest)
|
|
104
199
|
except OSError:
|
|
105
|
-
shutil.copytree(skill_path,
|
|
200
|
+
shutil.copytree(skill_path, dest, dirs_exist_ok=True)
|
|
201
|
+
return dest
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def resolve_fixtures(files: list, project_root: Path,
|
|
205
|
+
cases_dir: Path) -> list[tuple[Path, str]]:
|
|
206
|
+
"""Map each `files` entry to (source, dest-relative-path).
|
|
207
|
+
|
|
208
|
+
The entry's own relative path is preserved inside the cwd, so a bare
|
|
209
|
+
filename lands at the workspace root and a nested path keeps its
|
|
210
|
+
directory structure — matching the path the case input references.
|
|
211
|
+
"""
|
|
212
|
+
out: list[tuple[Path, str]] = []
|
|
213
|
+
for entry in files or []:
|
|
214
|
+
entry = str(entry)
|
|
215
|
+
for candidate in (
|
|
216
|
+
(project_root / entry).resolve(),
|
|
217
|
+
(cases_dir / entry).resolve(),
|
|
218
|
+
Path(entry).resolve(),
|
|
219
|
+
):
|
|
220
|
+
if candidate.is_file():
|
|
221
|
+
out.append((candidate, entry))
|
|
222
|
+
break
|
|
223
|
+
else:
|
|
224
|
+
print(f"Warning: fixture not found: {entry}", file=sys.stderr)
|
|
225
|
+
return out
|
|
226
|
+
|
|
106
227
|
|
|
228
|
+
def stage_fixtures(fixtures: list[tuple[Path, str]], cwd: Path) -> None:
|
|
107
229
|
for src, dest_rel in fixtures:
|
|
108
|
-
dest =
|
|
230
|
+
dest = cwd / dest_rel
|
|
109
231
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
110
232
|
shutil.copy2(src, dest)
|
|
111
233
|
|
|
112
|
-
return project_dest
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
def run_eval_local(
|
|
116
|
-
eval_item: dict,
|
|
117
|
-
run_dir: Path,
|
|
118
|
-
skill_path: Path,
|
|
119
|
-
project_root: Path,
|
|
120
|
-
timeout: int,
|
|
121
|
-
setup_dirs: list[Path] | None = None,
|
|
122
|
-
) -> dict:
|
|
123
|
-
eval_id = str(eval_item.get("id", "unnamed"))
|
|
124
|
-
eval_dir = run_dir / eval_id
|
|
125
|
-
workspace_root = eval_dir / "workspace"
|
|
126
|
-
artifacts_dir = eval_dir / "artifacts"
|
|
127
|
-
transcript_path = eval_dir / "transcript.jsonl"
|
|
128
|
-
|
|
129
|
-
eval_dir.mkdir(parents=True, exist_ok=True)
|
|
130
|
-
artifacts_dir.mkdir(parents=True, exist_ok=True)
|
|
131
|
-
|
|
132
|
-
fixtures = resolve_fixtures(eval_item.get("files", []), project_root)
|
|
133
|
-
workspace_project = stage_workspace_local(
|
|
134
|
-
workspace_root, project_root, skill_path, fixtures, setup_dirs
|
|
135
|
-
)
|
|
136
234
|
|
|
137
|
-
|
|
138
|
-
workspace_snapshot_before = snapshot_files(workspace_project)
|
|
235
|
+
# --- case composition -------------------------------------------------------
|
|
139
236
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
env = {
|
|
143
|
-
"HOME": str(home_dir),
|
|
144
|
-
"CLAUDE_CONFIG_DIR": str(home_dir / ".claude"),
|
|
145
|
-
"PATH": os.environ.get("PATH", ""),
|
|
146
|
-
"ANTHROPIC_API_KEY": os.environ.get("ANTHROPIC_API_KEY", ""),
|
|
147
|
-
}
|
|
237
|
+
def compose_prompt(case: dict) -> str:
|
|
238
|
+
"""Apply state_prefix by prepending it to the input.
|
|
148
239
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
240
|
+
The state_prefix is a bracketed prime that places the skill mid-workflow in
|
|
241
|
+
one shot. Prepending keeps the input intact and visible to the grader.
|
|
242
|
+
"""
|
|
243
|
+
input_text = str(case.get("input", ""))
|
|
244
|
+
prefix = case.get("state_prefix")
|
|
245
|
+
if prefix:
|
|
246
|
+
return f"{str(prefix).rstrip()}\n\n{input_text}"
|
|
247
|
+
return input_text
|
|
156
248
|
|
|
157
|
-
start = time.time()
|
|
158
|
-
try:
|
|
159
|
-
with transcript_path.open("wb") as out:
|
|
160
|
-
proc = subprocess.run(
|
|
161
|
-
cmd,
|
|
162
|
-
stdout=out,
|
|
163
|
-
stderr=subprocess.PIPE,
|
|
164
|
-
cwd=str(workspace_project),
|
|
165
|
-
env=env,
|
|
166
|
-
timeout=timeout,
|
|
167
|
-
)
|
|
168
|
-
elapsed = time.time() - start
|
|
169
|
-
return_code = proc.returncode
|
|
170
|
-
stderr_tail = (proc.stderr or b"").decode("utf-8", errors="replace")[-2000:]
|
|
171
|
-
except subprocess.TimeoutExpired as e:
|
|
172
|
-
elapsed = time.time() - start
|
|
173
|
-
return_code = -1
|
|
174
|
-
stderr_tail = f"TIMEOUT after {timeout}s"
|
|
175
|
-
if e.stderr:
|
|
176
|
-
stderr_tail += "\n" + e.stderr.decode("utf-8", errors="replace")[-2000:]
|
|
177
249
|
|
|
178
|
-
|
|
179
|
-
|
|
250
|
+
# --- transcript + token accounting -----------------------------------------
|
|
251
|
+
|
|
252
|
+
def read_transcript(transcript_cfg: dict, captured_stdout: bytes,
|
|
253
|
+
cwd: Path) -> tuple[str, str]:
|
|
254
|
+
"""Return (transcript_text, source). Source names where it came from."""
|
|
255
|
+
fmt = (transcript_cfg or {}).get("format", "stdout-jsonl")
|
|
256
|
+
if fmt == "file":
|
|
257
|
+
rel = (transcript_cfg or {}).get("path", "transcript.jsonl")
|
|
258
|
+
f = cwd / rel
|
|
259
|
+
if f.is_file():
|
|
260
|
+
return f.read_text(encoding="utf-8", errors="replace"), f"file:{rel}"
|
|
261
|
+
return "", f"file:{rel} (missing)"
|
|
262
|
+
return captured_stdout.decode("utf-8", errors="replace"), "stdout"
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def account_transcript(transcript_text: str) -> dict:
|
|
266
|
+
"""Pull timing/token usage from a JSONL transcript when present.
|
|
267
|
+
|
|
268
|
+
Reads usage out of the completion notification immediately, so tokens are
|
|
269
|
+
captured at run time rather than recomputed later. Recognizes the common
|
|
270
|
+
`result` event with a usage block and per-message usage blocks; unknown
|
|
271
|
+
shapes degrade to zero counts without failing.
|
|
272
|
+
"""
|
|
273
|
+
input_tokens = 0
|
|
274
|
+
output_tokens = 0
|
|
275
|
+
total_steps = 0
|
|
276
|
+
tool_calls: dict[str, int] = {}
|
|
277
|
+
found_usage = False
|
|
180
278
|
|
|
181
|
-
|
|
182
|
-
|
|
279
|
+
for raw in transcript_text.splitlines():
|
|
280
|
+
raw = raw.strip()
|
|
281
|
+
if not raw:
|
|
282
|
+
continue
|
|
283
|
+
try:
|
|
284
|
+
evt = json.loads(raw)
|
|
285
|
+
except json.JSONDecodeError:
|
|
286
|
+
continue
|
|
287
|
+
if not isinstance(evt, dict):
|
|
288
|
+
continue
|
|
289
|
+
etype = evt.get("type")
|
|
290
|
+
if etype == "assistant":
|
|
291
|
+
total_steps += 1
|
|
292
|
+
msg = evt.get("message", {})
|
|
293
|
+
usage = msg.get("usage") if isinstance(msg, dict) else None
|
|
294
|
+
if isinstance(usage, dict):
|
|
295
|
+
found_usage = True
|
|
296
|
+
input_tokens += int(usage.get("input_tokens", 0) or 0)
|
|
297
|
+
output_tokens += int(usage.get("output_tokens", 0) or 0)
|
|
298
|
+
for item in (msg.get("content", []) if isinstance(msg, dict) else []):
|
|
299
|
+
if isinstance(item, dict) and item.get("type") == "tool_use":
|
|
300
|
+
name = item.get("name", "?")
|
|
301
|
+
tool_calls[name] = tool_calls.get(name, 0) + 1
|
|
302
|
+
elif etype == "result":
|
|
303
|
+
usage = evt.get("usage")
|
|
304
|
+
if isinstance(usage, dict):
|
|
305
|
+
found_usage = True
|
|
306
|
+
# result usage is authoritative; prefer it over the running sum
|
|
307
|
+
input_tokens = int(usage.get("input_tokens", input_tokens) or input_tokens)
|
|
308
|
+
output_tokens = int(usage.get("output_tokens", output_tokens) or output_tokens)
|
|
183
309
|
|
|
184
310
|
return {
|
|
185
|
-
"
|
|
186
|
-
"
|
|
187
|
-
"
|
|
188
|
-
"
|
|
189
|
-
"
|
|
190
|
-
"
|
|
311
|
+
"input_tokens": input_tokens,
|
|
312
|
+
"output_tokens": output_tokens,
|
|
313
|
+
"total_tokens": input_tokens + output_tokens,
|
|
314
|
+
"tokens_reported": found_usage,
|
|
315
|
+
"total_steps": total_steps,
|
|
316
|
+
"tool_calls": tool_calls,
|
|
317
|
+
"total_tool_calls": sum(tool_calls.values()),
|
|
191
318
|
}
|
|
192
319
|
|
|
193
320
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
321
|
+
# --- per-case execution -----------------------------------------------------
|
|
322
|
+
|
|
323
|
+
def run_case(case: dict, case_dir: Path, run_dir: Path,
|
|
324
|
+
adapter: dict | None, timeout: int, config: str,
|
|
325
|
+
skill_path: Path | None,
|
|
326
|
+
fixtures: list[tuple[Path, str]]) -> dict:
|
|
327
|
+
case_id = str(case.get("id", "unnamed"))
|
|
328
|
+
cwd = case_dir / "cwd"
|
|
329
|
+
cwd.mkdir(parents=True, exist_ok=True)
|
|
330
|
+
|
|
331
|
+
stage_fixtures(fixtures, cwd)
|
|
332
|
+
if skill_path is not None:
|
|
333
|
+
skills_subdir = (adapter or {}).get("skill_dir", ".claude/skills")
|
|
334
|
+
stage_skill(skill_path, cwd, skills_subdir)
|
|
335
|
+
|
|
336
|
+
prompt = compose_prompt(case)
|
|
337
|
+
(case_dir / "prompt.txt").write_text(prompt, encoding="utf-8")
|
|
338
|
+
write_json(case_dir / "case.json", case)
|
|
339
|
+
|
|
340
|
+
if adapter is None:
|
|
341
|
+
result = {
|
|
342
|
+
"case_id": case_id,
|
|
343
|
+
"config": config,
|
|
344
|
+
"status": "skipped",
|
|
345
|
+
"reason": "no runtime adapter configured",
|
|
346
|
+
"prompt_chars": len(prompt),
|
|
347
|
+
"cwd": str(cwd.relative_to(run_dir)),
|
|
348
|
+
}
|
|
349
|
+
write_json(case_dir / "timing.json", {
|
|
350
|
+
"case_id": case_id, "config": config, "status": "skipped",
|
|
351
|
+
"captured_at": utc_now_iso(),
|
|
352
|
+
})
|
|
353
|
+
return result
|
|
217
354
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
# Pre-merge setup overlay dirs on the host; mount as /setup:ro in the container.
|
|
221
|
-
setup_merged: Path | None = None
|
|
222
|
-
if setup_dirs:
|
|
223
|
-
setup_merged = eval_dir / "setup_merged"
|
|
224
|
-
apply_setup_overlay(setup_dirs, setup_merged)
|
|
225
|
-
if not any(setup_merged.iterdir()):
|
|
226
|
-
setup_merged = None
|
|
227
|
-
|
|
228
|
-
creds_dir: Path | None = None
|
|
229
|
-
if _KEYCHAIN_CREDS:
|
|
230
|
-
creds_dir = eval_dir / "creds"
|
|
231
|
-
creds_dir.mkdir(parents=True, exist_ok=True)
|
|
232
|
-
(creds_dir / ".credentials.json").write_text(_KEYCHAIN_CREDS, encoding="utf-8")
|
|
233
|
-
|
|
234
|
-
container_script = r"""
|
|
235
|
-
set -e
|
|
236
|
-
mkdir -p /workspace
|
|
237
|
-
rsync -a \
|
|
238
|
-
--exclude=.git --exclude=.bare --exclude=node_modules --exclude=.venv \
|
|
239
|
-
--exclude=__pycache__ --exclude=.pytest_cache --exclude=.next \
|
|
240
|
-
--exclude=dist --exclude=build --exclude=.cache --exclude=.DS_Store \
|
|
241
|
-
/project/ /workspace/
|
|
242
|
-
if [ -d /setup ]; then
|
|
243
|
-
rsync -a /setup/ /workspace/
|
|
244
|
-
fi
|
|
245
|
-
mkdir -p /workspace/.claude/skills
|
|
246
|
-
cp -R "$SKILL_SRC" "/workspace/.claude/skills/$SKILL_NAME"
|
|
247
|
-
if [ -d /fixtures ]; then
|
|
248
|
-
cp -R /fixtures/. /workspace/
|
|
249
|
-
fi
|
|
250
|
-
if [ -f /creds/.credentials.json ]; then
|
|
251
|
-
mkdir -p /home/evaluator/.claude
|
|
252
|
-
cp /creds/.credentials.json /home/evaluator/.claude/.credentials.json
|
|
253
|
-
fi
|
|
254
|
-
cd /workspace
|
|
255
|
-
claude -p "$EVAL_PROMPT" \
|
|
256
|
-
--output-format stream-json --verbose \
|
|
257
|
-
--dangerously-skip-permissions \
|
|
258
|
-
> /output/transcript.jsonl 2> /output/stderr.log || true
|
|
259
|
-
mkdir -p /output/artifacts
|
|
260
|
-
rsync -a --exclude=.claude --exclude=node_modules --exclude=.git \
|
|
261
|
-
--filter='+ */' --filter='+ *' \
|
|
262
|
-
/workspace/ /output/artifacts/
|
|
263
|
-
"""
|
|
355
|
+
transcript_path = case_dir / "transcript.jsonl"
|
|
356
|
+
argv = build_argv(adapter["invocation"], prompt, str(cwd))
|
|
264
357
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
"-v", f"{project_root}:/project:ro",
|
|
269
|
-
"-v", f"{skill_path}:/skill_src:ro",
|
|
270
|
-
"-v", f"{eval_dir}:/output",
|
|
271
|
-
"-e", "ANTHROPIC_API_KEY",
|
|
272
|
-
"-e", f"EVAL_PROMPT={eval_item['prompt']}",
|
|
273
|
-
"-e", f"SKILL_SRC=/skill_src",
|
|
274
|
-
"-e", f"SKILL_NAME={skill_name}",
|
|
275
|
-
]
|
|
276
|
-
if creds_dir:
|
|
277
|
-
cmd += ["-v", f"{creds_dir}:/creds:ro"]
|
|
278
|
-
if fixtures:
|
|
279
|
-
cmd += ["-v", f"{fixtures_staging}:/fixtures:ro"]
|
|
280
|
-
if setup_merged:
|
|
281
|
-
cmd += ["-v", f"{setup_merged}:/setup:ro"]
|
|
282
|
-
cmd += [DOCKER_IMAGE, "bash", "-c", container_script]
|
|
358
|
+
home_dir = case_dir / ".home"
|
|
359
|
+
(home_dir / ".claude").mkdir(parents=True, exist_ok=True)
|
|
360
|
+
env = build_case_env(adapter, home_dir, os.environ)
|
|
283
361
|
|
|
284
362
|
start = time.time()
|
|
363
|
+
captured = b""
|
|
364
|
+
return_code = 0
|
|
365
|
+
error_tail = ""
|
|
366
|
+
status = "ok"
|
|
285
367
|
try:
|
|
286
368
|
proc = subprocess.run(
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
369
|
+
argv,
|
|
370
|
+
stdout=subprocess.PIPE,
|
|
371
|
+
stderr=subprocess.PIPE,
|
|
372
|
+
cwd=str(cwd),
|
|
373
|
+
env=env,
|
|
374
|
+
timeout=timeout,
|
|
290
375
|
)
|
|
291
|
-
|
|
376
|
+
captured = proc.stdout or b""
|
|
292
377
|
return_code = proc.returncode
|
|
293
|
-
|
|
294
|
-
if
|
|
295
|
-
|
|
296
|
-
except
|
|
378
|
+
error_tail = (proc.stderr or b"").decode("utf-8", errors="replace")[-2000:]
|
|
379
|
+
if return_code != 0:
|
|
380
|
+
status = "error"
|
|
381
|
+
except FileNotFoundError as e:
|
|
382
|
+
# Adapter invocation command is not on PATH: degrade, do not crash.
|
|
297
383
|
elapsed = time.time() - start
|
|
384
|
+
write_json(case_dir / "timing.json", {
|
|
385
|
+
"case_id": case_id, "config": config, "status": "adapter-missing",
|
|
386
|
+
"elapsed_s": round(elapsed, 3), "captured_at": utc_now_iso(),
|
|
387
|
+
})
|
|
388
|
+
return {
|
|
389
|
+
"case_id": case_id,
|
|
390
|
+
"config": config,
|
|
391
|
+
"status": "adapter-missing",
|
|
392
|
+
"reason": f"invocation command not found: {e}",
|
|
393
|
+
"cwd": str(cwd.relative_to(run_dir)),
|
|
394
|
+
}
|
|
395
|
+
except subprocess.TimeoutExpired as e:
|
|
396
|
+
captured = e.stdout or b""
|
|
298
397
|
return_code = -1
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
398
|
+
status = "timeout"
|
|
399
|
+
error_tail = f"TIMEOUT after {timeout}s"
|
|
400
|
+
elapsed = time.time() - start
|
|
401
|
+
|
|
402
|
+
transcript_text, source = read_transcript(
|
|
403
|
+
adapter.get("transcript", {}), captured, cwd
|
|
404
|
+
)
|
|
405
|
+
transcript_path.write_text(transcript_text, encoding="utf-8")
|
|
406
|
+
|
|
407
|
+
accounting = account_transcript(transcript_text)
|
|
302
408
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
409
|
+
# Capture timing/tokens immediately to timing.json (run-time snapshot).
|
|
410
|
+
timing = {
|
|
411
|
+
"case_id": case_id,
|
|
412
|
+
"config": config,
|
|
413
|
+
"status": status,
|
|
414
|
+
"elapsed_s": round(elapsed, 3),
|
|
415
|
+
"return_code": return_code,
|
|
416
|
+
"transcript_source": source,
|
|
417
|
+
"input_tokens": accounting["input_tokens"],
|
|
418
|
+
"output_tokens": accounting["output_tokens"],
|
|
419
|
+
"total_tokens": accounting["total_tokens"],
|
|
420
|
+
"tokens_reported": accounting["tokens_reported"],
|
|
421
|
+
"total_steps": accounting["total_steps"],
|
|
422
|
+
"total_tool_calls": accounting["total_tool_calls"],
|
|
423
|
+
"captured_at": utc_now_iso(),
|
|
424
|
+
}
|
|
425
|
+
write_json(case_dir / "timing.json", timing)
|
|
306
426
|
|
|
307
427
|
return {
|
|
308
|
-
"
|
|
309
|
-
"
|
|
428
|
+
"case_id": case_id,
|
|
429
|
+
"config": config,
|
|
430
|
+
"status": status,
|
|
431
|
+
"elapsed_s": round(elapsed, 3),
|
|
310
432
|
"return_code": return_code,
|
|
311
433
|
"transcript": str(transcript_path.relative_to(run_dir)),
|
|
312
|
-
"
|
|
313
|
-
"
|
|
434
|
+
"cwd": str(cwd.relative_to(run_dir)),
|
|
435
|
+
"tokens": accounting["total_tokens"],
|
|
436
|
+
"tool_calls": accounting["tool_calls"],
|
|
437
|
+
"error_tail": error_tail,
|
|
314
438
|
}
|
|
315
439
|
|
|
316
440
|
|
|
317
|
-
|
|
318
|
-
out: list[tuple[Path, str]] = []
|
|
319
|
-
for entry in files:
|
|
320
|
-
candidate = (project_root / entry).resolve()
|
|
321
|
-
if not candidate.is_file():
|
|
322
|
-
alt = Path(entry).resolve()
|
|
323
|
-
if alt.is_file():
|
|
324
|
-
candidate = alt
|
|
325
|
-
else:
|
|
326
|
-
print(f"Warning: fixture not found: {entry}", file=sys.stderr)
|
|
327
|
-
continue
|
|
328
|
-
out.append((candidate, entry))
|
|
329
|
-
return out
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
def snapshot_files(root: Path) -> set[str]:
|
|
333
|
-
snap: set[str] = set()
|
|
334
|
-
for p in root.rglob("*"):
|
|
335
|
-
if p.is_file():
|
|
336
|
-
snap.add(str(p.relative_to(root)))
|
|
337
|
-
return snap
|
|
338
|
-
|
|
441
|
+
# --- main -------------------------------------------------------------------
|
|
339
442
|
|
|
340
|
-
def
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
continue
|
|
352
|
-
target = dest / rel
|
|
353
|
-
target.parent.mkdir(parents=True, exist_ok=True)
|
|
354
|
-
shutil.copy2(src, target)
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
def compute_metrics(transcript: Path, artifacts: Path, elapsed: float,
|
|
358
|
-
rc: int, stderr_tail: str) -> dict:
|
|
359
|
-
tool_calls: dict[str, int] = {}
|
|
360
|
-
total_steps = 0
|
|
361
|
-
if transcript.is_file():
|
|
362
|
-
for raw in transcript.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
363
|
-
raw = raw.strip()
|
|
364
|
-
if not raw:
|
|
365
|
-
continue
|
|
366
|
-
try:
|
|
367
|
-
evt = json.loads(raw)
|
|
368
|
-
except json.JSONDecodeError:
|
|
369
|
-
continue
|
|
370
|
-
if evt.get("type") == "assistant":
|
|
371
|
-
total_steps += 1
|
|
372
|
-
for item in evt.get("message", {}).get("content", []):
|
|
373
|
-
if item.get("type") == "tool_use":
|
|
374
|
-
name = item.get("name", "?")
|
|
375
|
-
tool_calls[name] = tool_calls.get(name, 0) + 1
|
|
376
|
-
|
|
377
|
-
output_chars = 0
|
|
378
|
-
for f in artifacts.rglob("*"):
|
|
379
|
-
if f.is_file():
|
|
380
|
-
try:
|
|
381
|
-
output_chars += f.stat().st_size
|
|
382
|
-
except OSError:
|
|
383
|
-
pass
|
|
384
|
-
|
|
385
|
-
return {
|
|
386
|
-
"elapsed_s": round(elapsed, 2),
|
|
387
|
-
"return_code": rc,
|
|
388
|
-
"tool_calls": tool_calls,
|
|
389
|
-
"total_tool_calls": sum(tool_calls.values()),
|
|
390
|
-
"total_steps": total_steps,
|
|
391
|
-
"output_chars": output_chars,
|
|
392
|
-
"transcript_chars": transcript.stat().st_size if transcript.is_file() else 0,
|
|
393
|
-
"stderr_tail": stderr_tail,
|
|
394
|
-
}
|
|
443
|
+
def load_cases(cases_file: Path) -> list[dict]:
|
|
444
|
+
data = read_json(cases_file)
|
|
445
|
+
if isinstance(data, dict) and "cases" in data:
|
|
446
|
+
cases = data["cases"]
|
|
447
|
+
elif isinstance(data, list):
|
|
448
|
+
cases = data
|
|
449
|
+
else:
|
|
450
|
+
raise ValueError("cases file must be a list or {'cases': [...]}")
|
|
451
|
+
if not isinstance(cases, list):
|
|
452
|
+
raise ValueError("'cases' must be a list")
|
|
453
|
+
return cases
|
|
395
454
|
|
|
396
455
|
|
|
397
|
-
def main() -> int:
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
456
|
+
def main(argv: list[str] | None = None) -> int:
|
|
457
|
+
p = argparse.ArgumentParser(
|
|
458
|
+
description=__doc__,
|
|
459
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
460
|
+
)
|
|
461
|
+
p.add_argument("--cases", required=True, type=Path)
|
|
462
|
+
p.add_argument("--skill-path", required=True, type=Path,
|
|
463
|
+
help="directory of the skill under test (contains SKILL.md)")
|
|
464
|
+
p.add_argument("--output-dir", required=True, type=Path)
|
|
465
|
+
p.add_argument("--mode", choices=("quality", "baseline", "variant"),
|
|
466
|
+
default="quality")
|
|
467
|
+
p.add_argument("--variant-path", type=Path, default=None,
|
|
468
|
+
help="variant mode: the stripped or prior-version skill")
|
|
469
|
+
p.add_argument("--project-root", type=Path, default=None,
|
|
470
|
+
help="base for resolving fixture paths; defaults to the "
|
|
471
|
+
"cases file's directory")
|
|
472
|
+
p.add_argument("--adapter", type=Path, default=None,
|
|
473
|
+
help="adapter config JSON; defaults to BMAD_EVAL_ADAPTER env "
|
|
474
|
+
"or adapter.json beside the cases file")
|
|
475
|
+
p.add_argument("--case-ids", default=None,
|
|
476
|
+
help="comma-separated subset of case ids to run")
|
|
477
|
+
p.add_argument("--runs", type=int, default=1,
|
|
478
|
+
help="repeats per case per config for the variance benchmark")
|
|
479
|
+
p.add_argument("--timeout", type=int, default=600)
|
|
480
|
+
p.add_argument("--workers", type=int, default=4)
|
|
481
|
+
p.add_argument("--label", default="evals", help="label for the run id")
|
|
482
|
+
p.add_argument("--quiet", action="store_true")
|
|
483
|
+
args = p.parse_args(argv)
|
|
484
|
+
|
|
485
|
+
cases_file = args.cases.resolve()
|
|
486
|
+
if not cases_file.is_file():
|
|
487
|
+
print(f"cases file not found: {cases_file}", file=sys.stderr)
|
|
488
|
+
return 2
|
|
409
489
|
|
|
410
490
|
skill_path = args.skill_path.resolve()
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
if not evals_file.is_file():
|
|
414
|
-
print(f"evals file not found: {evals_file}", file=sys.stderr)
|
|
491
|
+
if not (skill_path / "SKILL.md").is_file():
|
|
492
|
+
print(f"skill path has no SKILL.md: {skill_path}", file=sys.stderr)
|
|
415
493
|
return 2
|
|
416
494
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
495
|
+
if args.mode == "variant":
|
|
496
|
+
if args.variant_path is None:
|
|
497
|
+
print("--mode variant requires --variant-path", file=sys.stderr)
|
|
498
|
+
return 2
|
|
499
|
+
variant_path = args.variant_path.resolve()
|
|
500
|
+
if not (variant_path / "SKILL.md").is_file():
|
|
501
|
+
print(f"variant path has no SKILL.md: {variant_path}",
|
|
502
|
+
file=sys.stderr)
|
|
503
|
+
return 2
|
|
504
|
+
else:
|
|
505
|
+
variant_path = None
|
|
506
|
+
|
|
507
|
+
project_root = (args.project_root.resolve() if args.project_root
|
|
508
|
+
else cases_file.parent)
|
|
509
|
+
|
|
510
|
+
# Each config is (name, skill-to-stage-or-None). Baseline runs every case
|
|
511
|
+
# twice — skill staged and bare — so the floor is measured under
|
|
512
|
+
# identical conditions.
|
|
513
|
+
if args.mode == "baseline":
|
|
514
|
+
configs: list[tuple[str, Path | None]] = [
|
|
515
|
+
("skill", skill_path), ("bare", None)]
|
|
516
|
+
elif args.mode == "variant":
|
|
517
|
+
configs = [("skill", skill_path), ("variant", variant_path)]
|
|
518
|
+
else:
|
|
519
|
+
configs = [("skill", skill_path)]
|
|
420
520
|
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
521
|
+
cases = load_cases(cases_file)
|
|
522
|
+
if args.case_ids:
|
|
523
|
+
wanted = {x.strip() for x in args.case_ids.split(",") if x.strip()}
|
|
524
|
+
cases = [c for c in cases if str(c.get("id")) in wanted]
|
|
424
525
|
|
|
425
|
-
|
|
526
|
+
adapter_path = find_adapter(args.adapter, cases_file)
|
|
527
|
+
adapter: dict | None = None
|
|
528
|
+
adapter_note = "none"
|
|
529
|
+
if adapter_path is not None:
|
|
530
|
+
try:
|
|
531
|
+
adapter = load_adapter(adapter_path)
|
|
532
|
+
adapter_note = str(adapter_path)
|
|
533
|
+
except Exception as e:
|
|
534
|
+
print(f"adapter config invalid ({e}); degrading to skip-only",
|
|
535
|
+
file=sys.stderr)
|
|
536
|
+
adapter = None
|
|
537
|
+
adapter_note = f"invalid: {e}"
|
|
538
|
+
|
|
539
|
+
run_id = new_run_id(args.label)
|
|
426
540
|
run_dir = (args.output_dir / run_id).resolve()
|
|
427
541
|
run_dir.mkdir(parents=True, exist_ok=True)
|
|
428
542
|
|
|
429
543
|
write_json(run_dir / "run.json", {
|
|
430
544
|
"run_id": run_id,
|
|
431
|
-
"
|
|
545
|
+
"cases_file": str(cases_file),
|
|
432
546
|
"skill_path": str(skill_path),
|
|
433
|
-
"
|
|
434
|
-
"
|
|
435
|
-
"
|
|
547
|
+
"variant_path": str(variant_path) if variant_path else None,
|
|
548
|
+
"mode": args.mode,
|
|
549
|
+
"configs": [name for name, _ in configs],
|
|
550
|
+
"runs_per_case": args.runs,
|
|
551
|
+
"adapter": adapter_note,
|
|
436
552
|
"started_at": utc_now_iso(),
|
|
437
|
-
"
|
|
553
|
+
"case_count": len(cases),
|
|
438
554
|
})
|
|
439
555
|
|
|
440
|
-
|
|
556
|
+
if adapter is None and not args.quiet:
|
|
557
|
+
print("[run_evals] no runtime adapter configured; staging cases only "
|
|
558
|
+
"(no crash). Configure an adapter to execute.", file=sys.stderr)
|
|
441
559
|
|
|
442
560
|
results: list[dict] = []
|
|
443
561
|
if not args.quiet:
|
|
444
|
-
print(
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
562
|
+
print(f"[run_evals] {len(cases)} cases x {len(configs)} configs x "
|
|
563
|
+
f"{args.runs} runs, mode={args.mode}, run_dir={run_dir}",
|
|
564
|
+
file=sys.stderr)
|
|
565
|
+
|
|
566
|
+
jobs: list[tuple[str, dict, Path, Path | None]] = []
|
|
567
|
+
for config_name, config_skill in configs:
|
|
568
|
+
for c in cases:
|
|
569
|
+
base = run_dir / config_name / str(c.get("id", "unnamed"))
|
|
570
|
+
for i in range(max(1, args.runs)):
|
|
571
|
+
case_dir = base / f"run-{i + 1}" if args.runs > 1 else base
|
|
572
|
+
jobs.append((config_name, c, case_dir, config_skill))
|
|
573
|
+
|
|
574
|
+
with ThreadPoolExecutor(max_workers=max(1, args.workers)) as pool:
|
|
575
|
+
fut_to_case = {
|
|
576
|
+
pool.submit(run_case, c, case_dir, run_dir, adapter,
|
|
577
|
+
int(c.get("timeout", args.timeout)), config_name,
|
|
578
|
+
config_skill,
|
|
579
|
+
resolve_fixtures(c.get("files", []), project_root,
|
|
580
|
+
cases_file.parent)): c
|
|
581
|
+
for config_name, c, case_dir, config_skill in jobs
|
|
461
582
|
}
|
|
462
|
-
for fut in as_completed(
|
|
463
|
-
|
|
583
|
+
for fut in as_completed(fut_to_case):
|
|
584
|
+
c = fut_to_case[fut]
|
|
464
585
|
try:
|
|
465
586
|
res = fut.result()
|
|
466
587
|
except Exception as e:
|
|
467
|
-
res = {"
|
|
588
|
+
res = {"case_id": str(c.get("id")), "status": "exception",
|
|
589
|
+
"reason": str(e)}
|
|
468
590
|
results.append(res)
|
|
469
591
|
if not args.quiet:
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
f" [{status}] eval {res.get('eval_id')} ({res.get('elapsed_s', 0):.1f}s)",
|
|
474
|
-
file=sys.stderr,
|
|
475
|
-
)
|
|
592
|
+
print(f" [{res.get('status')}] {res.get('config', '?')}/"
|
|
593
|
+
f"{res.get('case_id')} ({res.get('elapsed_s', 0)}s)",
|
|
594
|
+
file=sys.stderr)
|
|
476
595
|
|
|
477
596
|
summary = {
|
|
478
597
|
"run_id": run_id,
|
|
479
598
|
"completed_at": utc_now_iso(),
|
|
480
|
-
"
|
|
481
|
-
"
|
|
482
|
-
"
|
|
599
|
+
"mode": args.mode,
|
|
600
|
+
"total": len(jobs),
|
|
601
|
+
"executed": sum(1 for r in results if r.get("status") == "ok"),
|
|
602
|
+
"skipped": sum(1 for r in results if r.get("status") == "skipped"),
|
|
603
|
+
"failures": sum(1 for r in results
|
|
604
|
+
if r.get("status") in ("error", "timeout", "exception",
|
|
605
|
+
"adapter-missing")),
|
|
483
606
|
"run_dir": str(run_dir),
|
|
484
607
|
"results": results,
|
|
485
608
|
}
|