Vectrum
Sign In
315 lines · 8.9 KB · Go
// 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)
}