bigpowers 2.89.1 → 2.90.1
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/.gemini/extensions/bigpowers/gemini-extension.json +1 -1
- package/.pi/package.json +1 -1
- package/CHANGELOG.md +19 -0
- package/extensions/omp-hooks.ts +8 -0
- package/package.json +1 -1
- package/scripts/ab-sandbox.sh +85 -0
- package/scripts/lib/__pycache__/link_utils.cpython-310.pyc +0 -0
- package/scripts/lib/__pycache__/srp_engine_links.cpython-310.pyc +0 -0
- package/scripts/lib/__pycache__/srp_engine_okf.cpython-310.pyc +0 -0
- package/scripts/lib/ab-arm-bigpowers.sh +42 -0
- package/scripts/lib/ab-eval.py +285 -0
- package/scripts/lib/ab-sandbox/agent.Dockerfile +33 -0
- package/scripts/lib/ab-sandbox/filter +6 -0
- package/scripts/lib/ab-sandbox/proxy.Dockerfile +13 -0
- package/scripts/lib/ab-sandbox/tinyproxy.conf +19 -0
- package/scripts/lib/ab_metrics.py +123 -0
- package/scripts/lib/ab_progress.py +103 -0
- package/scripts/lib/ab_report.py +116 -0
- package/scripts/lib/plan-consistency-check.sh +4 -2
- package/scripts/lib/run-benchmark.py +30 -16
- package/scripts/omp-smoke.ts +27 -1
- package/scripts/run-ab-eval.sh +7 -0
- package/scripts/run-story-verify.sh +7 -0
- package/scripts/validate-omp-extension.sh +22 -0
- package/tests/test_ab_ablation.py +91 -0
- package/tests/test_ab_eval.py +309 -0
- package/tests/test_ab_pi.py +63 -0
- package/tests/test_ab_progress.py +81 -0
- package/tests/test_ab_sandbox.py +94 -0
- package/tests/test_ab_score.py +113 -0
package/.pi/package.json
CHANGED
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,22 @@
|
|
|
1
|
+
## [2.90.1](https://github.com/danielvm-git/bigpowers/compare/v2.90.0...v2.90.1) (2026-11-03)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* **omp:** keep the tool_call branch probe quiet outside a git repo ([#190](https://github.com/danielvm-git/bigpowers/issues/190)) ([bcacf33](https://github.com/danielvm-git/bigpowers/commit/bcacf33ec72a8705c6b0dbbcf91f2ea2bed4b406)), closes [#189](https://github.com/danielvm-git/bigpowers/issues/189)
|
|
7
|
+
|
|
8
|
+
# [2.90.0](https://github.com/danielvm-git/bigpowers/compare/v2.89.1...v2.90.0) (2026-11-03)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **scripts:** name the failing test when story verify goes red ([#188](https://github.com/danielvm-git/bigpowers/issues/188)) ([45fe450](https://github.com/danielvm-git/bigpowers/commit/45fe4505caea2522038951d5774c6a5b5ae5cb57))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Features
|
|
17
|
+
|
|
18
|
+
* **benchmarks:** run the A/B eval in a Docker sandbox (e58s07) ([#187](https://github.com/danielvm-git/bigpowers/issues/187)) ([9377f16](https://github.com/danielvm-git/bigpowers/commit/9377f1642682afcde5387afbfb9373f11b0ec40c))
|
|
19
|
+
|
|
1
20
|
## [2.89.1](https://github.com/danielvm-git/bigpowers/compare/v2.89.0...v2.89.1) (2026-11-03)
|
|
2
21
|
|
|
3
22
|
|
package/extensions/omp-hooks.ts
CHANGED
|
@@ -205,11 +205,19 @@ function inGitRepo(cwd?: string): boolean {
|
|
|
205
205
|
}
|
|
206
206
|
|
|
207
207
|
function currentBranch(cwd: string): string {
|
|
208
|
+
// Probing a directory that is not a repository makes git print
|
|
209
|
+
// "fatal: not a git repository (or any of the parent directories): .git".
|
|
210
|
+
// execSync inherits fd 2 when no stdio option is given, so that message is
|
|
211
|
+
// painted straight over the TUI input line and vanishes on the next redraw
|
|
212
|
+
// (BUG-2026-11-03-omp-hooks-stderr-leak, issue #189). Ask inGitRepo() first —
|
|
213
|
+
// it is silent — so a non-repo target spawns no git process at all.
|
|
214
|
+
if (!inGitRepo(cwd)) return "unknown";
|
|
208
215
|
try {
|
|
209
216
|
return execSync("git rev-parse --abbrev-ref HEAD", {
|
|
210
217
|
encoding: "utf-8",
|
|
211
218
|
timeout: 3000,
|
|
212
219
|
cwd,
|
|
220
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
213
221
|
}).trim();
|
|
214
222
|
} catch {
|
|
215
223
|
return "unknown";
|
package/package.json
CHANGED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# story: e58s07
|
|
3
|
+
# ab-sandbox.sh — up|down|status for the A/B eval Docker sandbox.
|
|
4
|
+
#
|
|
5
|
+
# up builds the pinned agent and proxy images, creates an internal network (no route to the
|
|
6
|
+
# internet) plus a bridge only the proxy joins, and starts the proxy.
|
|
7
|
+
# down stops the proxy and removes the networks. Images stay cached.
|
|
8
|
+
# status exit 0 only when both images exist, both networks exist and the proxy is running.
|
|
9
|
+
set -euo pipefail
|
|
10
|
+
|
|
11
|
+
AGENT_IMAGE="bp-ab-agent:pi-1.5.0"
|
|
12
|
+
PROXY_IMAGE="bp-ab-proxy:tinyproxy-1"
|
|
13
|
+
PROXY_NAME="bp-ab-proxy"
|
|
14
|
+
INTERNAL_NET="bp-ab-internal"
|
|
15
|
+
EGRESS_NET="bp-ab-egress"
|
|
16
|
+
ASSETS="$(cd "$(dirname "${BASH_SOURCE[0]}")/lib/ab-sandbox" && pwd)"
|
|
17
|
+
|
|
18
|
+
die() { echo "ERROR: $*" >&2; exit 1; }
|
|
19
|
+
|
|
20
|
+
have_image() { docker image inspect "$1" >/dev/null 2>&1; }
|
|
21
|
+
have_net() { docker network inspect "$1" >/dev/null 2>&1; }
|
|
22
|
+
proxy_running() { [ "$(docker inspect -f '{{.State.Running}}' "$PROXY_NAME" 2>/dev/null)" = "true" ]; }
|
|
23
|
+
|
|
24
|
+
build() {
|
|
25
|
+
echo "Building $AGENT_IMAGE"
|
|
26
|
+
docker build -q -f "$ASSETS/agent.Dockerfile" -t "$AGENT_IMAGE" "$ASSETS" >/dev/null
|
|
27
|
+
echo "Building $PROXY_IMAGE"
|
|
28
|
+
docker build -q -f "$ASSETS/proxy.Dockerfile" -t "$PROXY_IMAGE" "$ASSETS" >/dev/null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
ensure_networks() {
|
|
32
|
+
have_net "$INTERNAL_NET" || docker network create --internal "$INTERNAL_NET" >/dev/null
|
|
33
|
+
have_net "$EGRESS_NET" || docker network create "$EGRESS_NET" >/dev/null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
wait_for_proxy() {
|
|
37
|
+
local i
|
|
38
|
+
for i in $(seq 1 30); do
|
|
39
|
+
if docker exec "$PROXY_NAME" sh -c 'nc -z 127.0.0.1 8888' >/dev/null 2>&1; then
|
|
40
|
+
echo "Proxy listening on $INTERNAL_NET:8888"
|
|
41
|
+
return 0
|
|
42
|
+
fi
|
|
43
|
+
sleep 1
|
|
44
|
+
done
|
|
45
|
+
docker logs "$PROXY_NAME" >&2 || true
|
|
46
|
+
die "proxy did not start listening"
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
cmd_up() {
|
|
50
|
+
build
|
|
51
|
+
ensure_networks
|
|
52
|
+
# The proxy joins the internal network first, then the bridge: the agent side can only see it.
|
|
53
|
+
docker rm -f "$PROXY_NAME" >/dev/null 2>&1 || true
|
|
54
|
+
docker run -d --name "$PROXY_NAME" --network "$INTERNAL_NET" --restart unless-stopped \
|
|
55
|
+
"$PROXY_IMAGE" >/dev/null
|
|
56
|
+
docker network connect "$EGRESS_NET" "$PROXY_NAME"
|
|
57
|
+
wait_for_proxy
|
|
58
|
+
echo "Sandbox up: $AGENT_IMAGE on $INTERNAL_NET (internet only via $PROXY_NAME, allowlist: opencode.ai)"
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
cmd_down() {
|
|
62
|
+
docker rm -f "$PROXY_NAME" >/dev/null 2>&1 || true
|
|
63
|
+
docker network rm "$INTERNAL_NET" >/dev/null 2>&1 || true
|
|
64
|
+
docker network rm "$EGRESS_NET" >/dev/null 2>&1 || true
|
|
65
|
+
echo "Sandbox down (images kept)"
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
cmd_status() {
|
|
69
|
+
local ok=0
|
|
70
|
+
for image in "$AGENT_IMAGE" "$PROXY_IMAGE"; do
|
|
71
|
+
if have_image "$image"; then echo "image $image"; else echo "MISSING $image"; ok=1; fi
|
|
72
|
+
done
|
|
73
|
+
for net in "$INTERNAL_NET" "$EGRESS_NET"; do
|
|
74
|
+
if have_net "$net"; then echo "network $net"; else echo "MISSING $net"; ok=1; fi
|
|
75
|
+
done
|
|
76
|
+
if proxy_running; then echo "proxy $PROXY_NAME running"; else echo "MISSING $PROXY_NAME running"; ok=1; fi
|
|
77
|
+
return "$ok"
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
case "${1:-status}" in
|
|
81
|
+
up) cmd_up ;;
|
|
82
|
+
down) cmd_down ;;
|
|
83
|
+
status) cmd_status ;;
|
|
84
|
+
*) die "usage: $(basename "$0") up|down|status" ;;
|
|
85
|
+
esac
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# story: e58s01 e58s04
|
|
3
|
+
# ab-arm-bigpowers.sh — provision an A/B eval workdir the way a bigpowers user has it.
|
|
4
|
+
# Runs inside the workdir. $AB_BP_ROOT is the bigpowers checkout.
|
|
5
|
+
# Copies (never symlinks): the agent must not be able to walk back into the repo,
|
|
6
|
+
# where the hidden tests live.
|
|
7
|
+
#
|
|
8
|
+
# Ablation options (e58s04):
|
|
9
|
+
# AB_BP_SKILLS all (default) | none | comma list of skill names
|
|
10
|
+
# AB_BP_SEED seed file relative to $AB_BP_ROOT (default: the bigpowers arm seed) | none
|
|
11
|
+
# scripts/ is copied only when at least one skill is.
|
|
12
|
+
set -euo pipefail
|
|
13
|
+
: "${AB_BP_ROOT:?AB_BP_ROOT must point at the bigpowers checkout}"
|
|
14
|
+
SKILLS="${AB_BP_SKILLS:-all}"
|
|
15
|
+
SEED="${AB_BP_SEED:-specs/benchmarks/ab/arms/bigpowers/CLAUDE.md}"
|
|
16
|
+
|
|
17
|
+
skill_dirs() {
|
|
18
|
+
case "$SKILLS" in
|
|
19
|
+
all) for d in "$AB_BP_ROOT"/skills/*/; do echo "${d%/}"; done ;;
|
|
20
|
+
none) ;;
|
|
21
|
+
*) for name in ${SKILLS//,/ }; do
|
|
22
|
+
[[ -d "$AB_BP_ROOT/skills/$name" ]] || { echo "unknown skill: $name" >&2; exit 2; }
|
|
23
|
+
echo "$AB_BP_ROOT/skills/$name"
|
|
24
|
+
done ;;
|
|
25
|
+
esac
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
mkdir -p specs/bugs specs/verifications
|
|
29
|
+
dirs="$(skill_dirs)"
|
|
30
|
+
if [[ -n "$dirs" ]]; then
|
|
31
|
+
mkdir -p .claude/skills .agents/skills
|
|
32
|
+
while IFS= read -r skill_dir; do
|
|
33
|
+
name="$(basename "$skill_dir")"
|
|
34
|
+
cp -R "$skill_dir" ".claude/skills/$name"
|
|
35
|
+
cp -R "$skill_dir" ".agents/skills/$name"
|
|
36
|
+
done <<< "$dirs"
|
|
37
|
+
cp -R "$AB_BP_ROOT/scripts" scripts
|
|
38
|
+
fi
|
|
39
|
+
if [[ "$SEED" != "none" ]]; then
|
|
40
|
+
cp "$AB_BP_ROOT/$SEED" CLAUDE.md
|
|
41
|
+
cp CLAUDE.md AGENTS.md
|
|
42
|
+
fi
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# story: e58s01 e58s02 e58s03 e58s05 e58s06 e58s07
|
|
3
|
+
"""A/B eval: run the same tasks with and without bigpowers and grade only the outcome.
|
|
4
|
+
|
|
5
|
+
Each run gets a fresh temp git workdir (outside the repo) copied from the task fixture.
|
|
6
|
+
The arm setup runs, the agent runs through an adapter command, then hidden tests are
|
|
7
|
+
copied in and the task's grade command decides pass/fail. Advisory: exit 0 after a
|
|
8
|
+
completed run, 2 on configuration errors.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import gzip
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
import tempfile
|
|
20
|
+
import time
|
|
21
|
+
from datetime import datetime, timezone
|
|
22
|
+
from difflib import SequenceMatcher
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
import yaml
|
|
26
|
+
|
|
27
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
28
|
+
from ab_metrics import parse_metrics, tap_score # type: ignore[import] # noqa: E402
|
|
29
|
+
from ab_report import ( # type: ignore[import] # noqa: E402
|
|
30
|
+
BOOTSTRAP_RESAMPLES, BOOTSTRAP_SEED, aggregate, bigpowers_sha, print_summary, write_reports,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
34
|
+
AB_DIR = REPO_ROOT / "specs" / "benchmarks" / "ab"
|
|
35
|
+
DEFAULT_CONFIG = AB_DIR / "config.yaml"
|
|
36
|
+
DEFAULT_TASKS_DIR = AB_DIR / "tasks"
|
|
37
|
+
DEFAULT_OUT = REPO_ROOT / "specs" / "benchmarks" / "reports"
|
|
38
|
+
DEFAULT_ARMS = "vanilla,bigpowers"
|
|
39
|
+
GIT_ID = ["-c", "user.name=ab-eval", "-c", "user.email=ab-eval@localhost", "-c", "commit.gpgsign=false"]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ConfigError(Exception):
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ── loading ──────────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
def load_yaml(path: Path) -> dict:
|
|
49
|
+
if not path.is_file():
|
|
50
|
+
raise ConfigError(f"missing file: {path}")
|
|
51
|
+
return yaml.safe_load(path.read_text()) or {}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def load_tasks(tasks_dir: Path, wanted: list[str] | None) -> list[dict]:
|
|
55
|
+
tasks = {}
|
|
56
|
+
for task_yaml in sorted(tasks_dir.glob("*/task.yaml")):
|
|
57
|
+
task = load_yaml(task_yaml)
|
|
58
|
+
task["dir"] = task_yaml.parent
|
|
59
|
+
tasks[task["id"]] = task
|
|
60
|
+
if not wanted:
|
|
61
|
+
return list(tasks.values())
|
|
62
|
+
unknown = [t for t in wanted if t not in tasks]
|
|
63
|
+
if unknown:
|
|
64
|
+
raise ConfigError(f"unknown task(s) {unknown}; valid: {sorted(tasks)}")
|
|
65
|
+
return [tasks[t] for t in wanted]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def pick(section: dict, names: list[str], kind: str) -> dict:
|
|
69
|
+
unknown = [n for n in names if n not in section]
|
|
70
|
+
if unknown:
|
|
71
|
+
raise ConfigError(f"unknown {kind}(s) {unknown}; valid: {sorted(section)}")
|
|
72
|
+
return {n: section[n] for n in names}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def resolve_adapter(config: dict, name: str, custom_cmd: str | None, parser: str | None) -> dict:
|
|
76
|
+
if custom_cmd:
|
|
77
|
+
return {"name": "custom", "command": custom_cmd, "parser": parser or "none"}
|
|
78
|
+
adapter = dict(pick(config.get("adapters", {}), [name], "adapter")[name])
|
|
79
|
+
adapter["name"] = name
|
|
80
|
+
if parser:
|
|
81
|
+
adapter["parser"] = parser
|
|
82
|
+
return adapter
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ── one run ──────────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
def sh(cmd: str, cwd: Path, env: dict | None = None, timeout: float | None = None):
|
|
88
|
+
# stdin closed: `opencode run` otherwise waits on an open stdin forever (e58s02).
|
|
89
|
+
return subprocess.run(["bash", "-c", cmd], cwd=cwd, env=env, stdin=subprocess.DEVNULL,
|
|
90
|
+
capture_output=True, text=True, timeout=timeout)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def git(workdir: Path, *args: str) -> str:
|
|
94
|
+
res = subprocess.run(["git", *GIT_ID, *args], cwd=workdir, capture_output=True, text=True, check=True)
|
|
95
|
+
return res.stdout.strip()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def snapshot(workdir: Path, message: str) -> str:
|
|
99
|
+
git(workdir, "add", "-A")
|
|
100
|
+
git(workdir, "commit", "-q", "--allow-empty", "--no-verify", "-m", message)
|
|
101
|
+
return git(workdir, "rev-parse", "HEAD")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def file_lines(workdir: Path, paths: list[str]) -> dict:
|
|
105
|
+
return {rel: (workdir / rel).read_text().splitlines() if (workdir / rel).is_file() else None
|
|
106
|
+
for rel in paths}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def protected_changes(workdir: Path, before: dict) -> tuple[bool, int]:
|
|
110
|
+
"""Tampered = an original line removed or changed. Added lines are counted, not flagged:
|
|
111
|
+
a TDD arm adds tests to the visible files, and hidden tests decide the grade (e58s02)."""
|
|
112
|
+
tampered, added = False, 0
|
|
113
|
+
for rel, now in file_lines(workdir, list(before)).items():
|
|
114
|
+
if before[rel] is None or now is None:
|
|
115
|
+
tampered = tampered or before[rel] is not None
|
|
116
|
+
continue
|
|
117
|
+
for op, _, _, j1, j2 in SequenceMatcher(None, before[rel], now, autojunk=False).get_opcodes():
|
|
118
|
+
added += j2 - j1 if op == "insert" else 0
|
|
119
|
+
tampered = tampered or op not in ("equal", "insert")
|
|
120
|
+
return tampered, added
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def changed_lines(workdir: Path, base: str) -> int:
|
|
124
|
+
git(workdir, "add", "-A")
|
|
125
|
+
total = 0
|
|
126
|
+
for line in git(workdir, "diff", "--cached", "--numstat", base).splitlines():
|
|
127
|
+
added, deleted, _ = line.split("\t", 2)
|
|
128
|
+
total += (int(added) if added.isdigit() else 0) + (int(deleted) if deleted.isdigit() else 0)
|
|
129
|
+
return total
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def save_trace(path: Path, stdout: str) -> None:
|
|
133
|
+
"""Agent transcript for later process analysis (e58s05)."""
|
|
134
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
135
|
+
path.write_bytes(gzip.compress(stdout.encode()))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def env_for(prompt: str, model: str | None, workdir: Path, home: Path, timeout_s: int = 900) -> dict:
|
|
139
|
+
"""$AB_HOME is a fresh empty dir per run: adapters point HOME at it to hide global skills.
|
|
140
|
+
$AB_TIMEOUT leaves 30 s for an in-container `timeout` to stop the agent first (e58s07)."""
|
|
141
|
+
env = dict(os.environ)
|
|
142
|
+
env.update({"AB_PROMPT": prompt, "AB_MODEL": model or "", "AB_WORKDIR": str(workdir),
|
|
143
|
+
"AB_HOME": str(home), "AB_BP_ROOT": str(REPO_ROOT), "AB_TIMEOUT": str(max(10, timeout_s - 30))})
|
|
144
|
+
return env
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def grade_cmd(adapter: dict, cmd: str, env: dict) -> tuple[str, dict]:
|
|
148
|
+
"""Runs grade/score through the adapter's grade_wrapper (e.g. a no-network container)."""
|
|
149
|
+
if not adapter.get("grade_wrapper"):
|
|
150
|
+
return cmd, env
|
|
151
|
+
return adapter["grade_wrapper"], {**env, "AB_CMD": cmd}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def run_once(task: dict, arm: str, setup: str, adapter: dict, model: str | None,
|
|
155
|
+
run_idx: int, keep: bool, trace_dir: Path | None = None) -> dict:
|
|
156
|
+
workdir = Path(tempfile.mkdtemp(prefix=f"bp-ab-{task['id']}-{arm}-"))
|
|
157
|
+
home = Path(tempfile.mkdtemp(prefix="bp-ab-home-"))
|
|
158
|
+
try:
|
|
159
|
+
shutil.copytree(task["dir"] / "fixture", workdir, dirs_exist_ok=True)
|
|
160
|
+
git(workdir, "init", "-q")
|
|
161
|
+
snapshot(workdir, "fixture")
|
|
162
|
+
env = env_for(task["prompt"], model, workdir, home, task.get("timeout_s", 900))
|
|
163
|
+
if sh(setup, workdir, env).returncode != 0:
|
|
164
|
+
raise ConfigError(f"arm '{arm}' setup failed in {workdir}")
|
|
165
|
+
base = snapshot(workdir, f"arm {arm}")
|
|
166
|
+
protected = task.get("protected", [])
|
|
167
|
+
before = file_lines(workdir, protected)
|
|
168
|
+
|
|
169
|
+
started = time.monotonic()
|
|
170
|
+
timed_out = False
|
|
171
|
+
try:
|
|
172
|
+
agent = sh(adapter["command"], workdir, env, timeout=task.get("timeout_s", 900))
|
|
173
|
+
exit_code, stdout = agent.returncode, agent.stdout
|
|
174
|
+
except subprocess.TimeoutExpired as exc:
|
|
175
|
+
timed_out, exit_code = True, None
|
|
176
|
+
stdout = exc.stdout.decode() if isinstance(exc.stdout, bytes) else (exc.stdout or "")
|
|
177
|
+
duration = round(time.monotonic() - started, 1)
|
|
178
|
+
if trace_dir is not None:
|
|
179
|
+
save_trace(trace_dir / f"{task['id']}-{arm}-{run_idx}.jsonl.gz", stdout)
|
|
180
|
+
|
|
181
|
+
tampered, protected_added = protected_changes(workdir, before)
|
|
182
|
+
diff = changed_lines(workdir, base)
|
|
183
|
+
shutil.copytree(task["dir"] / "hidden", workdir, dirs_exist_ok=True)
|
|
184
|
+
cmd, genv = grade_cmd(adapter, task["grade"], env)
|
|
185
|
+
passed = sh(cmd, workdir, genv, timeout=300).returncode == 0
|
|
186
|
+
score = None
|
|
187
|
+
if task.get("score"):
|
|
188
|
+
cmd, genv = grade_cmd(adapter, task["score"], env)
|
|
189
|
+
score = tap_score(sh(cmd, workdir, genv, timeout=300).stdout)
|
|
190
|
+
metrics = parse_metrics(adapter.get("parser", "none"), stdout)
|
|
191
|
+
return {
|
|
192
|
+
"task": task["id"], "arm": arm, "run": run_idx, "pass": passed, "score": score, "tampered": tampered,
|
|
193
|
+
"protected_lines_added": protected_added, "diff_lines": diff, "duration_s": duration, "agent_exit": exit_code,
|
|
194
|
+
"agent_error": timed_out or exit_code != 0 or bool(metrics.pop("agent_reported_error")),
|
|
195
|
+
"timed_out": timed_out, **metrics,
|
|
196
|
+
}
|
|
197
|
+
finally:
|
|
198
|
+
shutil.rmtree(home, ignore_errors=True)
|
|
199
|
+
if keep:
|
|
200
|
+
print(f" kept workdir: {workdir}")
|
|
201
|
+
else:
|
|
202
|
+
shutil.rmtree(workdir, ignore_errors=True)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# ── CLI ──────────────────────────────────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
def parse_args(argv: list[str] | None) -> argparse.Namespace:
|
|
208
|
+
p = argparse.ArgumentParser(description="A/B eval: vanilla agent vs bigpowers, outcome-graded")
|
|
209
|
+
p.add_argument("--config", type=Path, default=DEFAULT_CONFIG)
|
|
210
|
+
p.add_argument("--tasks-dir", type=Path, default=DEFAULT_TASKS_DIR)
|
|
211
|
+
p.add_argument("--tasks", help="comma-separated task ids (default: all)")
|
|
212
|
+
p.add_argument("--arms", default=DEFAULT_ARMS)
|
|
213
|
+
p.add_argument("--k", type=int, help="runs per task per arm (default: config k)")
|
|
214
|
+
p.add_argument("--adapter", help="adapter name (default: config default_adapter, else claude)")
|
|
215
|
+
p.add_argument("--adapter-cmd", help="custom adapter command; prompt is in $AB_PROMPT")
|
|
216
|
+
p.add_argument("--parser", help="output parser: claude-json | none")
|
|
217
|
+
p.add_argument("--model", help="exported as $AB_MODEL for the adapter")
|
|
218
|
+
p.add_argument("--out", type=Path, default=DEFAULT_OUT)
|
|
219
|
+
p.add_argument("--keep", action="store_true", help="keep workdirs for inspection")
|
|
220
|
+
p.add_argument("--dry-run", action="store_true", help="print the run matrix; run nothing")
|
|
221
|
+
return p.parse_args(argv)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def main(argv: list[str] | None = None) -> int:
|
|
225
|
+
args = parse_args(argv)
|
|
226
|
+
try:
|
|
227
|
+
config = load_yaml(args.config)
|
|
228
|
+
arm_names = [a.strip() for a in args.arms.split(",") if a.strip()]
|
|
229
|
+
arms = pick(config.get("arms", {}), arm_names, "arm")
|
|
230
|
+
adapter_name = args.adapter or config.get("default_adapter", "claude")
|
|
231
|
+
adapter = resolve_adapter(config, adapter_name, args.adapter_cmd, args.parser)
|
|
232
|
+
tasks = load_tasks(args.tasks_dir, args.tasks.split(",") if args.tasks else None)
|
|
233
|
+
if not tasks:
|
|
234
|
+
raise ConfigError(f"no tasks found in {args.tasks_dir}")
|
|
235
|
+
except ConfigError as exc:
|
|
236
|
+
print(f"ERROR: {exc}")
|
|
237
|
+
return 2
|
|
238
|
+
k = args.k or int(config.get("k", 3))
|
|
239
|
+
model = args.model or adapter.get("default_model")
|
|
240
|
+
|
|
241
|
+
print(f"A/B eval: {len(tasks)} task(s) x {len(arms)} arm(s) x k={k} via adapter '{adapter['name']}'"
|
|
242
|
+
f"{f' model {model}' if model else ''}")
|
|
243
|
+
if args.dry_run:
|
|
244
|
+
for task in tasks:
|
|
245
|
+
for arm, spec in arms.items():
|
|
246
|
+
print(f" {task['id']:<8} {arm:<10} setup: {spec['setup']}")
|
|
247
|
+
print(f" adapter command: {adapter['command']}")
|
|
248
|
+
return 0
|
|
249
|
+
|
|
250
|
+
# Round-robin (e58s05): any prefix of the run covers every arm, and slow provider periods hit
|
|
251
|
+
# all arms alike. Each record is appended to ab-live.jsonl as soon as its run finishes.
|
|
252
|
+
records = []
|
|
253
|
+
sha = bigpowers_sha()
|
|
254
|
+
args.out.mkdir(parents=True, exist_ok=True)
|
|
255
|
+
live = args.out / "ab-live.jsonl"
|
|
256
|
+
try:
|
|
257
|
+
for i in range(1, k + 1):
|
|
258
|
+
for task in tasks:
|
|
259
|
+
for arm, spec in arms.items():
|
|
260
|
+
rec = run_once(task, arm, spec["setup"], adapter, model, i, args.keep, args.out / "traces")
|
|
261
|
+
records.append(rec)
|
|
262
|
+
with live.open("a") as f:
|
|
263
|
+
f.write(json.dumps({**rec, "bigpowers_sha": sha, "model": model}) + "\n")
|
|
264
|
+
print(f" {task['id']:<8} {arm:<10} run {i}: {'PASS' if rec['pass'] else 'FAIL'}"
|
|
265
|
+
f"{' TAMPERED' if rec['tampered'] else ''}{' AGENT-ERROR' if rec['agent_error'] else ''}")
|
|
266
|
+
except ConfigError as exc:
|
|
267
|
+
print(f"ERROR: {exc}")
|
|
268
|
+
return 2
|
|
269
|
+
|
|
270
|
+
result = aggregate(records, list(arms), [t["id"] for t in tasks])
|
|
271
|
+
meta = {
|
|
272
|
+
"generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
273
|
+
"bigpowers_sha": sha, "adapter": adapter["name"], "model": model,
|
|
274
|
+
"k": k, "tasks": [t["id"] for t in tasks], "arms": list(arms),
|
|
275
|
+
"arm_setup": {a: s["setup"] for a, s in arms.items()},
|
|
276
|
+
"ci_method": f"bootstrap over runs, {BOOTSTRAP_RESAMPLES} resamples, seed {BOOTSTRAP_SEED}",
|
|
277
|
+
}
|
|
278
|
+
report = write_reports(args.out, meta, result, records)
|
|
279
|
+
print_summary(result)
|
|
280
|
+
print(f"Wrote {report}")
|
|
281
|
+
return 0
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
if __name__ == "__main__":
|
|
285
|
+
sys.exit(main())
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# story: e58s07
|
|
2
|
+
# Agent image for the A/B eval sandbox. Nothing from the host is baked in: the runner mounts
|
|
3
|
+
# only the run's workdir and its fresh $AB_HOME. Egress is the internal network (no internet)
|
|
4
|
+
# plus the allowlisting proxy, so the only reachable host is the model provider.
|
|
5
|
+
FROM node@sha256:c86ecead9e55a5210ac1727de38a4ca840bf03dcaf34dc9749addbf383b1920c
|
|
6
|
+
|
|
7
|
+
ARG PI_VERSION=1.5.0
|
|
8
|
+
|
|
9
|
+
RUN apt-get update \
|
|
10
|
+
&& apt-get install -y --no-install-recommends bash ca-certificates curl git python3 \
|
|
11
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
12
|
+
|
|
13
|
+
RUN npm install -g --no-audit --no-fund "@earendil-works/pi-coding-agent@${PI_VERSION}" \
|
|
14
|
+
&& npm cache clean --force
|
|
15
|
+
|
|
16
|
+
# Node 24 ignores HTTP(S)_PROXY in fetch() unless this is set; without it the container cannot
|
|
17
|
+
# reach the proxy at all. The grading container sets --network none, so the proxy is irrelevant there.
|
|
18
|
+
ENV NODE_USE_ENV_PROXY=1 \
|
|
19
|
+
HTTPS_PROXY=http://bp-ab-proxy:8888 \
|
|
20
|
+
HTTP_PROXY=http://bp-ab-proxy:8888 \
|
|
21
|
+
NO_PROXY=localhost,127.0.0.1
|
|
22
|
+
|
|
23
|
+
# node:24-bookworm-slim already ships uid 1000 as `node`; reuse it instead of creating a user.
|
|
24
|
+
RUN mkdir -p /work /home/node/.pi/agent \
|
|
25
|
+
&& chown -R node:node /work /home/node
|
|
26
|
+
|
|
27
|
+
# The workdir comes from a host mount owned by another uid; without this, git in the container
|
|
28
|
+
# refuses to run against it ("dubious ownership").
|
|
29
|
+
RUN git config --system --add safe.directory '*'
|
|
30
|
+
|
|
31
|
+
USER node
|
|
32
|
+
WORKDIR /work
|
|
33
|
+
CMD ["bash"]
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# story: e58s07
|
|
2
|
+
# Allowlist for the A/B eval proxy (FilterDefaultDeny Yes: everything else is refused).
|
|
3
|
+
# opencode.ai serves the OpenCode Go inference endpoints pi uses:
|
|
4
|
+
# https://opencode.ai/inference/go/openai/v1
|
|
5
|
+
^opencode\.ai(:[0-9]+)?$
|
|
6
|
+
^.*\.opencode\.ai(:[0-9]+)?$
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# story: e58s07
|
|
2
|
+
# Allowlisting forward proxy: the agent container has no route to the internet, so its only way
|
|
3
|
+
# out is this proxy, which permits the model provider and nothing else.
|
|
4
|
+
FROM alpine@sha256:2e0b81d60597f449baa56dc25d355367869d2dca4cd57e866f1e2b52af3eb55f
|
|
5
|
+
|
|
6
|
+
RUN apk add --no-cache tinyproxy
|
|
7
|
+
|
|
8
|
+
COPY tinyproxy.conf /etc/tinyproxy/tinyproxy.conf
|
|
9
|
+
COPY filter /etc/tinyproxy/filter
|
|
10
|
+
|
|
11
|
+
EXPOSE 8888
|
|
12
|
+
USER tinyproxy
|
|
13
|
+
ENTRYPOINT ["tinyproxy", "-d", "-c", "/etc/tinyproxy/tinyproxy.conf"]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# story: e58s07
|
|
2
|
+
Port 8888
|
|
3
|
+
Listen 0.0.0.0
|
|
4
|
+
Timeout 900
|
|
5
|
+
MaxClients 200
|
|
6
|
+
StartServers 5
|
|
7
|
+
MinSpareServers 5
|
|
8
|
+
MaxSpareServers 20
|
|
9
|
+
# Every client is a container on the internal network, so admit all of them and deny by destination.
|
|
10
|
+
Allow 0.0.0.0/0
|
|
11
|
+
DisableViaHeader Yes
|
|
12
|
+
# CONNECT is the only method that matters (the provider is https).
|
|
13
|
+
ConnectPort 443
|
|
14
|
+
Filter "/etc/tinyproxy/filter"
|
|
15
|
+
FilterDefaultDeny Yes
|
|
16
|
+
FilterExtended On
|
|
17
|
+
FilterCaseSensitive Off
|
|
18
|
+
FilterURLs On
|
|
19
|
+
LogLevel Info
|