Files
2026-07-13 13:00:08 +08:00

431 lines
13 KiB
Go

// e2ebench runs the committed e2e task suite against a real provider and emits a
// markdown + JSON report (accuracy, cache-hit rate, token use, cost) for a PR.
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
"github.com/BurntSushi/toml"
fileencoding "reasonix/internal/fileutil/encoding"
)
type task struct {
ID string
Prompt string `toml:"prompt"`
MaxSteps int `toml:"max_steps"`
TimeoutSec int `toml:"timeout_sec"`
dir string
}
type runMetrics struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
CacheHitTokens int `json:"cache_hit_tokens"`
CacheMissTokens int `json:"cache_miss_tokens"`
Steps int `json:"steps"`
Cost float64 `json:"cost"`
Currency string `json:"currency"`
Compactions int `json:"compactions"`
// Optional Delivery capability counters (omitempty for baseline/old metrics).
ReadinessChecks int `json:"readiness_checks,omitempty"`
ReadinessRecoveries int `json:"readiness_recoveries,omitempty"`
CapabilityRoutes int `json:"capability_routes,omitempty"`
CapabilityRoutedCandidates int `json:"capability_routed_candidates,omitempty"`
CapabilityRoutedRequire int `json:"capability_routed_require,omitempty"`
CapabilityRoutedPrefer int `json:"capability_routed_prefer,omitempty"`
CapabilityRoutedSuggest int `json:"capability_routed_suggest,omitempty"`
CapabilityDeclines int `json:"capability_declines,omitempty"`
CapabilitySemanticRoutes int `json:"capability_semantic_routes,omitempty"`
CapabilitySkillInvocations int `json:"capability_skill_invocations,omitempty"`
CapabilityMCPCall int `json:"capability_mcp_call,omitempty"`
CapabilityReviewBlocks int `json:"capability_review_blocks,omitempty"`
CapabilityRouterCost float64 `json:"capability_router_cost,omitempty"`
CapabilityRouterLatencyMs int64 `json:"capability_router_latency_ms,omitempty"`
}
type result struct {
task
runMetrics
Profile string `json:"profile"`
Passed bool
Skipped bool
Note string
}
func main() {
flag.Usage = func() {
fmt.Fprintf(flag.CommandLine.Output(), "e2ebench — Reasonix end-to-end benchmark.\n\n")
fmt.Fprintf(flag.CommandLine.Output(), "Usage of %s:\n", flag.CommandLine.Name())
flag.PrintDefaults()
fmt.Fprintf(flag.CommandLine.Output(), "\nExamples:\n")
fmt.Fprintf(flag.CommandLine.Output(), " # Run the committed suite:\n")
fmt.Fprintf(flag.CommandLine.Output(), " %[1]s\n\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
fmt.Fprintf(flag.CommandLine.Output(), " # Grade a PR's diff with a retry budget:\n")
fmt.Fprintf(flag.CommandLine.Output(), " %[1]s -mode diff -base origin/main -repo . -attempts 3 -timeout 1800\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
fmt.Fprintf(flag.CommandLine.Output(), "\n # Run the same suite with the delivery contract:\n")
fmt.Fprintf(flag.CommandLine.Output(), " %[1]s -profile delivery\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
}
mode := flag.String("mode", "suite", "suite | diff (diff = generate tests for the PR diff and grade with the repo's tests)")
suite := flag.String("suite", "benchmarks/e2e", "suite root (contains tasks/<id>/)")
bin := flag.String("bin", "reasonix", "path to the reasonix binary")
model := flag.String("model", "", "provider/model name (default: config default)")
profileFlag := flag.String("profile", benchmarkProfileBaseline, "prompt profile: baseline | delivery")
outMD := flag.String("out", "", "write the markdown report here (default: stdout)")
outJSON := flag.String("json", "", "write the JSON report here (optional)")
budget := flag.Int("budget", 400_000, "abort once total tokens cross this (0 = no cap)")
// diff-mode flags
repo := flag.String("repo", ".", "repo root (diff mode)")
base := flag.String("base", "", "base ref to diff the PR head against (diff mode)")
testCmd := flag.String("test-cmd", "go test", "grader command run on the affected packages (diff mode)")
maxSteps := flag.Int("max-steps", 80, "agent tool-call cap for the diff task")
timeoutSec := flag.Int("timeout", 1200, "agent timeout in seconds (diff mode)")
attempts := flag.Int("attempts", 1, "diff mode: retry up to N times until a run passes (stochastic agent)")
flag.Parse()
profile, err := normalizeBenchmarkProfile(*profileFlag)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
if *mode == "diff" {
report := runDiff(diffOpts{
bin: *bin, model: *model, repo: *repo, base: *base,
testCmd: *testCmd, profile: profile, maxSteps: *maxSteps, timeoutSec: *timeoutSec, attempts: *attempts,
})
emit(report, *outMD, "")
return
}
tasks, err := loadTasks(*suite)
if err != nil {
fmt.Fprintln(os.Stderr, "load suite:", err)
os.Exit(1)
}
if len(tasks) == 0 {
dir := filepath.Join(*suite, "tasks")
if _, statErr := os.Stat(dir); statErr != nil {
fmt.Fprintf(os.Stderr, "no tasks found under %s: %v\n", dir, statErr)
} else {
fmt.Fprintf(os.Stderr, "no tasks found under %s (the directory exists but contains no task.toml files)\n", dir)
}
os.Exit(1)
}
var results []result
total := 0
for _, t := range tasks {
if *budget > 0 && total >= *budget {
results = append(results, result{task: t, Profile: profile, Skipped: true, Note: "skipped: token budget reached"})
continue
}
r := runTask(*bin, *model, profile, t)
total += r.PromptTokens + r.CompletionTokens
results = append(results, r)
}
report := render(results)
if *outMD != "" {
if err := os.WriteFile(*outMD, []byte(report), 0o644); err != nil {
fmt.Fprintln(os.Stderr, "write report:", err)
os.Exit(1)
}
} else {
fmt.Print(report)
}
if *outJSON != "" {
b, err := json.MarshalIndent(results, "", " ")
if err != nil {
fmt.Fprintln(os.Stderr, "marshal json:", err)
os.Exit(1)
}
if err := os.WriteFile(*outJSON, b, 0o644); err != nil {
fmt.Fprintln(os.Stderr, "write json:", err)
os.Exit(1)
}
}
}
func emit(report, outMD, _ string) {
if outMD != "" {
if err := os.WriteFile(outMD, []byte(report), 0o644); err != nil {
fmt.Fprintln(os.Stderr, "write report:", err)
os.Exit(1)
}
return
}
fmt.Print(report)
}
func loadTasks(suite string) ([]task, error) {
tasksDir := filepath.Join(suite, "tasks")
entries, err := os.ReadDir(tasksDir)
if err != nil {
return nil, err
}
var tasks []task
for _, e := range entries {
if !e.IsDir() {
continue
}
dir := filepath.Join(tasksDir, e.Name())
var t task
data, err := fileencoding.ReadFileUTF8(filepath.Join(dir, "task.toml"))
if err != nil {
return nil, fmt.Errorf("%s: %w", e.Name(), err)
}
if _, err := toml.Decode(string(data), &t); err != nil {
return nil, fmt.Errorf("%s: %w", e.Name(), err)
}
t.ID = e.Name()
t.dir = dir
if t.TimeoutSec == 0 {
t.TimeoutSec = 240
}
tasks = append(tasks, t)
}
sort.Slice(tasks, func(i, j int) bool { return tasks[i].ID < tasks[j].ID })
return tasks, nil
}
// runTask copies the task's seed workdir into a temp dir, runs the agent there,
// then drops in verify.sh and runs it as the grader. The grader is added only
// after the run so the agent can't read the answer key.
func runTask(bin, model, profile string, t task) result {
r := result{task: t, Profile: profile}
work, err := os.MkdirTemp("", "e2ebench-"+t.ID+"-")
if err != nil {
r.Note = "mktemp: " + err.Error()
return r
}
defer os.RemoveAll(work)
if seed := filepath.Join(t.dir, "workdir"); dirExists(seed) {
if err := copyDir(seed, work); err != nil {
r.Note = "copy seed: " + err.Error()
return r
}
}
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(t.TimeoutSec)*time.Second)
defer cancel()
metricsPath := filepath.Join(work, ".run-metrics.json")
args := []string{"run", "--metrics", metricsPath}
if model != "" {
args = append(args, "--model", model)
}
if t.MaxSteps > 0 {
args = append(args, "--max-steps", fmt.Sprint(t.MaxSteps))
}
args = appendBenchmarkProfileArgs(args, profile)
args = append(args, t.Prompt)
cmd := exec.CommandContext(ctx, bin, args...)
cmd.Dir = work
cmd.Stdout = os.Stderr // stream the run to the job log, keep stdout clean for the report
cmd.Stderr = os.Stderr
cmd.WaitDelay = 10 * time.Second // bound the wait for a stuck child after ctx timeout
runErr := cmd.Run()
if m, err := readMetrics(metricsPath); err == nil {
r.runMetrics = m
}
if runErr != nil {
r.Note = "run: " + runErr.Error()
// still grade — a non-zero exit may just be a max-steps notice
}
r.Passed = grade(work, t.dir)
return r
}
func grade(work, taskDir string) bool {
verify := filepath.Join(taskDir, "verify.sh")
if !fileExists(verify) {
return false
}
dst := filepath.Join(work, "verify.sh")
if err := copyFile(verify, dst); err != nil {
return false
}
cmd := exec.Command("bash", "verify.sh")
cmd.Dir = work
cmd.Stdout = os.Stderr
cmd.Stderr = os.Stderr
return cmd.Run() == nil
}
func render(results []result) string {
var b strings.Builder
passed, ran := 0, 0
var pTok, cTok, hit, miss, compacts int
var cost float64
currency := ""
for _, r := range results {
if r.Skipped {
continue
}
ran++
if r.Passed {
passed++
}
pTok += r.PromptTokens
cTok += r.CompletionTokens
hit += r.CacheHitTokens
miss += r.CacheMissTokens
compacts += r.Compactions
cost += r.Cost
if r.Currency != "" {
currency = r.Currency
}
}
profile := benchmarkProfileBaseline
if len(results) > 0 && results[0].Profile != "" {
profile = results[0].Profile
}
fmt.Fprintf(&b, "## 🤖 Reasonix e2e benchmark (%s)\n\n", profile)
fmt.Fprintf(&b, "**Accuracy:** %d/%d (%s) · **Cache hit:** %s · **Tokens:** %s (prompt %s / completion %s) · **Compactions:** %d · **Cost:** %s%.4f\n\n",
passed, ran, pct(passed, ran), pct(hit, hit+miss),
comma(pTok+cTok), comma(pTok), comma(cTok), compacts, currencySym(currency), cost)
fmt.Fprintf(&b, "| Task | Result | Steps | Prompt | Completion | Cache hit | Compact | Cost |\n")
fmt.Fprintf(&b, "|------|--------|------:|-------:|-----------:|----------:|--------:|-----:|\n")
for _, r := range results {
switch {
case r.Skipped:
fmt.Fprintf(&b, "| `%s` | ⏭️ skipped | — | — | — | — | — | — |\n", r.ID)
default:
res := "❌ fail"
if r.Passed {
res = "✅ pass"
}
fmt.Fprintf(&b, "| `%s` | %s | %d | %s | %s | %s | %d | %s%.4f |\n",
r.ID, res, r.Steps, comma(r.PromptTokens), comma(r.CompletionTokens),
pct(r.CacheHitTokens, r.CacheHitTokens+r.CacheMissTokens),
r.Compactions, currencySym(r.Currency), r.Cost)
}
}
fmt.Fprintf(&b, "\n<sub>Real provider run. Cache-hit %% is cached prompt tokens / total prompt tokens.</sub>\n")
notes := false
for _, r := range results {
if r.Note != "" {
if !notes {
fmt.Fprintf(&b, "\n<details><summary>Notes</summary>\n\n")
notes = true
}
fmt.Fprintf(&b, "- `%s`: %s\n", r.ID, r.Note)
}
}
if notes {
fmt.Fprintf(&b, "\n</details>\n")
}
return b.String()
}
func pct(n, d int) string {
if d == 0 {
return "n/a"
}
return fmt.Sprintf("%.0f%%", 100*float64(n)/float64(d))
}
func comma(n int) string {
s := fmt.Sprint(n)
if len(s) <= 3 {
return s
}
var out []byte
for i, c := range []byte(s) {
if i > 0 && (len(s)-i)%3 == 0 {
out = append(out, ',')
}
out = append(out, c)
}
return string(out)
}
func currencySym(c string) string {
if c == "" {
return ""
}
return c + " "
}
func readMetrics(path string) (runMetrics, error) {
var m runMetrics
b, err := fileencoding.ReadFileUTF8(path)
if err != nil {
return m, err
}
return m, json.Unmarshal(b, &m)
}
func dirExists(p string) bool {
fi, err := os.Stat(p)
return err == nil && fi.IsDir()
}
func fileExists(p string) bool {
fi, err := os.Stat(p)
return err == nil && !fi.IsDir()
}
func copyDir(src, dst string) error {
return filepath.Walk(src, func(p string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// Skip symlinks so a seed link can't leak a file from outside the seed tree.
if info.Mode()&os.ModeSymlink != 0 {
return nil
}
rel, _ := filepath.Rel(src, p)
target := filepath.Join(dst, rel)
if info.IsDir() {
return os.MkdirAll(target, 0o755)
}
return copyFile(p, target)
})
}
func copyFile(src, dst string) error {
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
info, err := in.Stat()
if err != nil {
return err
}
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode().Perm())
if err != nil {
return err
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
return err
}
// Mirror the source mode so a seed's read-only / exec bit survives the copy.
return os.Chmod(dst, info.Mode().Perm())
}