// Command evals runs the task suite and prints the numbers that go in // METRICS.md. Everything after Phase 3 gets measured; everything before it was // guesswork. // // go run ./evals -n 3 package main import ( "bytes" "context" "encoding/json" "flag" "fmt" "os" "os/exec" "path/filepath" "sort" "strings" "sync" "time" "codey/internal/agent" "codey/internal/approval" "codey/internal/llm" "codey/internal/metrics" "codey/internal/tools" ) // Task is one directory under evals/tasks. type Task struct { Name string Dir string Prompt string } // Result is one run of one task. Persisted verbatim so trajectories can be read // back later; most regressions are obvious within ten seconds of reading one. type Result struct { Task string `json:"task"` Run int `json:"run"` Model string `json:"model"` Pass bool `json:"pass"` Turns int `json:"turns"` Usage llm.Usage `json:"usage"` WallMS int64 `json:"wall_ms"` Error string `json:"error,omitempty"` Answer string `json:"answer"` Verify string `json:"verify_output,omitempty"` Messages []llm.Message `json:"messages"` Events []metricEvent `json:"events"` } type metricEvent struct { Type string `json:"type"` Tool string `json:"tool,omitempty"` OK *bool `json:"ok,omitempty"` Err string `json:"err,omitempty"` } func main() { if err := run(); err != nil { fmt.Fprintln(os.Stderr, "evals:", err) os.Exit(1) } } func run() error { n := flag.Int("n", 1, "how many times to run each task; agents are nondeterministic, so one run tells you nothing") only := flag.String("tasks", "", "comma-separated task names to run (default: all)") parallel := flag.Int("parallel", 4, "how many runs at once") model := flag.String("model", "", "model id") effort := flag.String("effort", "", "output_config.effort") maxTurns := flag.Int("max-turns", 40, "turn cap per run") contextWindow := flag.Int("context-window", agent.DefaultContextWindow, "model context window") noCache := flag.Bool("no-cache", false, "disable prompt cache breakpoints, to measure what they buy") noStream := flag.Bool("no-stream", false, "use the non-streaming endpoint") timeout := flag.Duration("timeout", 10*time.Minute, "wall-clock cap per run") tasksDir := flag.String("dir", "evals/tasks", "directory of task definitions") outDir := flag.String("out", "evals/runs", "where to persist transcripts") keep := flag.Bool("keep", false, "keep the temporary work directories") flag.Parse() tasks, err := loadTasks(*tasksDir, *only) if err != nil { return err } if len(tasks) == 0 { return fmt.Errorf("no tasks found in %s", *tasksDir) } client := llm.New(llm.WithModel(*model)) if !client.HasCredentials() { return fmt.Errorf("no credentials: set ANTHROPIC_API_KEY") } stamp := time.Now().UTC().Format("20060102T150405Z") runRoot := filepath.Join(*outDir, stamp) if err := os.MkdirAll(runRoot, 0o755); err != nil { return err } fmt.Printf("%d tasks × %d runs · %s · transcripts in %s\n\n", len(tasks), *n, client.Model(), runRoot) type job struct { task Task run int } var jobs []job for _, t := range tasks { for i := 1; i <= *n; i++ { jobs = append(jobs, job{task: t, run: i}) } } var ( mu sync.Mutex results []Result sem = make(chan struct{}, *parallel) wg sync.WaitGroup ) for _, j := range jobs { wg.Add(1) go func(j job) { defer wg.Done() sem <- struct{}{} defer func() { <-sem }() res := runOne(j.task, j.run, client, agent.Options{ MaxTurns: *maxTurns, Effort: *effort, ContextWindow: *contextWindow, NoCache: *noCache, Stream: !*noStream, }, *timeout, *keep) mu.Lock() results = append(results, res) mu.Unlock() status := "FAIL" if res.Pass { status = "pass" } fmt.Printf(" %-26s run %d %-4s %2d turns %5.1fs %s\n", j.task.Name, j.run, status, res.Turns, float64(res.WallMS)/1000, res.Error) if err := persist(runRoot, res); err != nil { fmt.Fprintln(os.Stderr, "evals: persisting transcript:", err) } }(j) } wg.Wait() sort.Slice(results, func(i, j int) bool { if results[i].Task != results[j].Task { return results[i].Task < results[j].Task } return results[i].Run < results[j].Run }) report(os.Stdout, results) return persistSummary(runRoot, results) } // runOne sets up a fresh working directory, drives the agent headless, and // verifies the result. // The return value is named so the deferred timing assignment lands in the // value actually returned rather than in a copy already made. func runOne(task Task, runNo int, client *llm.Client, opts agent.Options, timeout time.Duration, keep bool) (res Result) { res = Result{Task: task.Name, Run: runNo, Model: client.Model()} start := time.Now() defer func() { res.WallMS = time.Since(start).Milliseconds() }() work, err := os.MkdirTemp("", "eval-"+task.Name+"-") if err != nil { res.Error = err.Error() return res } if !keep { defer os.RemoveAll(work) } if out, err := script(context.Background(), filepath.Join(task.Dir, "setup.sh"), work, task.Dir, 2*time.Minute); err != nil { res.Error = "setup failed: " + err.Error() res.Verify = out return res } ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() var logBuf bytes.Buffer log := metrics.NewWriter(&logBuf, fmt.Sprintf("%s-%d", task.Name, runNo)) state := agent.NewState() reg := tools.NewRegistry(log).Register( tools.NewRead(work, state), tools.NewGlob(work), tools.NewGrep(work), tools.NewEdit(work, state), tools.NewWrite(work, state), tools.NewBash(work), ) a := agent.New(client, reg, state, log, opts) // The eval runner is headless and operates on a throwaway temp directory, // so there is nothing to approve and no one to ask. yolo := approval.New(nil) yolo.YOLO = true a.Approve = yolo answer, runErr := a.Run(ctx, task.Prompt) res.Answer = answer if runErr != nil { res.Error = runErr.Error() } usage, turns := state.Usage() res.Usage, res.Turns = usage, turns res.Messages = state.History() res.Events = parseEvents(logBuf.Bytes()) // verify.sh may want to know what the agent claimed, which is the only way // to check a task whose correct outcome is "I could not do this". answerFile := filepath.Join(work, ".agent-answer.txt") os.WriteFile(answerFile, []byte(answer), 0o644) verifyOut, verifyErr := script(ctx, filepath.Join(task.Dir, "verify.sh"), work, task.Dir, 3*time.Minute) res.Verify = verifyOut res.Pass = verifyErr == nil return res } // script runs one of a task's shell files with the work directory as cwd. func script(ctx context.Context, path, work, taskDir string, timeout time.Duration) (string, error) { ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() // The script path must be absolute: cmd.Dir is the work directory, so a // path relative to the runner's cwd would not resolve. cmd := exec.CommandContext(ctx, "bash", mustAbs(path)) cmd.Dir = work cmd.Env = append(os.Environ(), "WORK_DIR="+work, "TASK_DIR="+mustAbs(taskDir), "AGENT_ANSWER_FILE="+filepath.Join(work, ".agent-answer.txt"), ) out, err := cmd.CombinedOutput() return string(out), err } func mustAbs(p string) string { abs, err := filepath.Abs(p) if err != nil { return p } return abs } func loadTasks(dir, only string) ([]Task, error) { entries, err := os.ReadDir(dir) if err != nil { return nil, err } wanted := map[string]bool{} for _, name := range strings.Split(only, ",") { if name = strings.TrimSpace(name); name != "" { wanted[name] = true } } var tasks []Task for _, e := range entries { if !e.IsDir() || (len(wanted) > 0 && !wanted[e.Name()]) { continue } taskDir := filepath.Join(dir, e.Name()) prompt, err := os.ReadFile(filepath.Join(taskDir, "prompt.txt")) if err != nil { return nil, fmt.Errorf("%s: %w", e.Name(), err) } for _, required := range []string{"setup.sh", "verify.sh"} { if _, err := os.Stat(filepath.Join(taskDir, required)); err != nil { return nil, fmt.Errorf("%s: missing %s", e.Name(), required) } } tasks = append(tasks, Task{Name: e.Name(), Dir: taskDir, Prompt: strings.TrimSpace(string(prompt))}) } return tasks, nil } func parseEvents(b []byte) []metricEvent { var out []metricEvent for _, line := range bytes.Split(b, []byte("\n")) { if len(bytes.TrimSpace(line)) == 0 { continue } var ev metricEvent if json.Unmarshal(line, &ev) == nil { out = append(out, ev) } } return out } func persist(runRoot string, res Result) error { dir := filepath.Join(runRoot, res.Task) if err := os.MkdirAll(dir, 0o755); err != nil { return err } b, err := json.MarshalIndent(res, "", " ") if err != nil { return err } return os.WriteFile(filepath.Join(dir, fmt.Sprintf("%d.json", res.Run)), b, 0o644) } func persistSummary(runRoot string, results []Result) error { var buf bytes.Buffer report(&buf, results) return os.WriteFile(filepath.Join(runRoot, "summary.txt"), buf.Bytes(), 0o644) }