diff --git a/CHANGELOG.md b/CHANGELOG.md index e793c19..3679e20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,21 @@ and [`agents/peggy/CHANGELOG.md`](agents/peggy/CHANGELOG.md). ## Unreleased +- **Evals-as-gates harness (`evals`, `glue eval`).** A new Go-native eval + harness: define a `Suite` of `Case`s, run them against a `*glue.Agent` + with `Runner`, score each with `Scorer`s (built-ins: `Contains`, + `NotContains`, `Regex`, `Equals`, `Command` — the coding gate that runs + a build/test and scores on exit 0 — `Judge` LLM-as-judge, and + `ScorerFunc`), and gate CI with `Report.Gate(minPassRate)`. Suites can + also be declarative JSON (`SuiteSpec`, unknown fields/scorer-types + rejected) and run via `glue eval [--coding] [--min-pass-rate] + [--parallel] [--json]`, which builds the agent under test plus a + separate tool-less judge agent and exits non-zero when the gate fails. + This is the missing feedback loop for the v1.13.0 harness work. + Borrowed from Vercel's Eve framework; see + [ADR-0019](docs/adr/0019-evals-harness.md), [docs/evals.md](docs/evals.md), + and [examples/evals/smoke.json](examples/evals/smoke.json). (#360) + - **TUI: `/` picker shows every command; `@` picker gains scroll indicators (`cmd/glue/tui`).** The slash-command popup previously reused the file picker's 8-row scroll window with no indicator, so a diff --git a/cmd/glue/evalcmd.go b/cmd/glue/evalcmd.go new file mode 100644 index 0000000..84ce323 --- /dev/null +++ b/cmd/glue/evalcmd.go @@ -0,0 +1,172 @@ +// The "glue eval" subcommand: run a declarative eval suite against an +// agent and gate (exit non-zero) on the aggregate pass rate. This is the +// CI-facing front end to the evals package. + +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "io" + "os" + "strings" + + "github.com/erain/glue" + "github.com/erain/glue/evals" +) + +func evalCommand(ctx context.Context, args []string, stdout, stderr io.Writer, newProvider providerFactory) error { + flags := flag.NewFlagSet("glue eval", flag.ContinueOnError) + flags.SetOutput(stderr) + + provider := flags.String("provider", defaultProvider, "provider name: codex, gemini, nvidia, or openrouter") + model := flags.String("model", "", "model id for the agent under test (default: provider default)") + judgeModel := flags.String("judge-model", "", "model id for judge scorers (default: --model)") + coding := flags.Bool("coding", false, "enable local coding tools for the agent under test") + workDir := flags.String("work", ".", "working directory for coding tools and command scorers") + storeDir := flags.String("store", ".glue/evals", "session store directory for eval runs") + minPassRate := flags.Float64("min-pass-rate", 1.0, "minimum suite pass rate to exit zero (0..1)") + parallel := flags.Int("parallel", 1, "max cases to run concurrently") + jsonOut := flags.Bool("json", false, "emit the report as JSON instead of text") + var allowedBinaries repeatedStrings + flags.Var(&allowedBinaries, "allow-binary", "allowed shell_exec binary basename for --coding; repeatable") + var envs envFiles + flags.Var(&envs, "env", "env file path; repeatable") + + // The suite path is the first argument; flags follow it (Go's flag + // parser stops at the first positional, so we split it off by hand). + if len(args) == 0 || strings.HasPrefix(args[0], "-") { + return fmt.Errorf("usage: glue eval [flags]") + } + suitePath := args[0] + if err := flags.Parse(args[1:]); err != nil { + return err + } + if flags.NArg() != 0 { + return fmt.Errorf("unexpected arguments after suite path: %s", strings.Join(flags.Args(), " ")) + } + + if err := loadEnvFiles(envs); err != nil { + return err + } + providerName, effectiveModel, err := resolveProvider(*provider, *model) + if err != nil { + return err + } + + // Agent under test: optional coding tools, auto-approving permission + // (eval runs are unattended — run them on a feature branch / worktree). + var tools []glue.Tool + if *coding { + tools, _, err = buildCodingTools(codingFlagConfig{ + Enabled: true, + WorkDir: *workDir, + AllowedBinaries: append([]string(nil), allowedBinaries...), + AllowOverwrite: true, + }) + if err != nil { + return err + } + } + agent, err := newAgent(newProvider, agentConfig{ + Provider: providerName, + Model: effectiveModel, + StoreDir: *storeDir, + WorkDir: *workDir, + Tools: tools, + Permission: yoloPermission{}, + Coding: *coding, + }) + if err != nil { + return err + } + + // Judge agent: independent of the agent under test, no tools, so an + // LLM-as-judge scorer never grades its own work. + judge, err := newAgent(newProvider, agentConfig{ + Provider: providerName, + Model: judgeModelOr(*judgeModel, effectiveModel), + StoreDir: *storeDir, + }) + if err != nil { + return err + } + + data, err := os.ReadFile(suitePath) + if err != nil { + return fmt.Errorf("read suite: %w", err) + } + suite, err := evals.ParseSuite(data, evals.BuildOptions{JudgeAgent: judge}) + if err != nil { + return err + } + + report, err := evals.Runner{Agent: agent, Parallelism: *parallel}.Run(ctx, suite) + if err != nil { + return err + } + + if *jsonOut { + if err := writeJSONReport(stdout, report); err != nil { + return err + } + } else { + report.WriteText(stdout) + } + + // Gate: a non-nil error here exits the process non-zero (CI gate). + return report.Gate(*minPassRate) +} + +func judgeModelOr(judge, fallback string) string { + if strings.TrimSpace(judge) != "" { + return judge + } + return fallback +} + +// writeJSONReport emits a stable, machine-readable view of the report for +// CI consumers. +func writeJSONReport(w io.Writer, r evals.Report) error { + type scoreJSON struct { + Scorer string `json:"scorer"` + Value float64 `json:"value"` + Reason string `json:"reason,omitempty"` + } + type caseJSON struct { + Name string `json:"name"` + Passed bool `json:"passed"` + Score float64 `json:"score"` + Error string `json:"error,omitempty"` + Scores []scoreJSON `json:"scores,omitempty"` + } + out := struct { + Suite string `json:"suite"` + Threshold float64 `json:"threshold"` + Passed int `json:"passed"` + Total int `json:"total"` + PassRate float64 `json:"pass_rate"` + Cases []caseJSON `json:"cases"` + }{ + Suite: r.Suite, + Threshold: r.PassThreshold, + Passed: r.Passed(), + Total: r.Total(), + PassRate: r.PassRate(), + } + for _, c := range r.Cases { + cj := caseJSON{Name: c.Name, Passed: c.Passed, Score: c.Score} + if c.Err != nil { + cj.Error = c.Err.Error() + } + for _, s := range c.Scores { + cj.Scores = append(cj.Scores, scoreJSON{Scorer: s.Scorer, Value: s.Value, Reason: s.Reason}) + } + out.Cases = append(out.Cases, cj) + } + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(out) +} diff --git a/cmd/glue/evalcmd_test.go b/cmd/glue/evalcmd_test.go new file mode 100644 index 0000000..455337d --- /dev/null +++ b/cmd/glue/evalcmd_test.go @@ -0,0 +1,107 @@ +package main + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/erain/glue" +) + +// fixedProvider always replies with the same text, regardless of how many +// times it is called — convenient for eval tests where the agent under +// test and the judge agent share one provider instance. +type fixedProvider struct{ text string } + +func (p fixedProvider) Stream(_ context.Context, _ glue.ProviderRequest) (<-chan glue.ProviderEvent, error) { + ch := make(chan glue.ProviderEvent, 3) + ch <- glue.ProviderEvent{Type: glue.ProviderEventStart} + ch <- glue.ProviderEvent{Type: glue.ProviderEventTextDelta, Delta: p.text} + ch <- glue.ProviderEvent{Type: glue.ProviderEventDone, Message: &glue.Message{ + Role: glue.MessageRoleAssistant, + Content: []glue.ContentPart{{Type: glue.ContentTypeText, Text: p.text}}, + StopReason: glue.StopReasonStop, + }} + close(ch) + return ch, nil +} + +func writeSuite(t *testing.T, body string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "suite.json") + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + return path +} + +const twoCaseSuite = `{ + "name": "smoke", + "cases": [ + {"name": "ok", "prompt": "say hello", "scorers": [{"type": "contains", "values": ["hello"]}]}, + {"name": "fail", "prompt": "say hello", "scorers": [{"type": "contains", "values": ["absent"]}]} + ] +}` + +func runEval(t *testing.T, provider glue.Provider, args ...string) (code int, stdout, stderr string) { + t.Helper() + var out, errb bytes.Buffer + full := append([]string{"eval"}, args...) + code = runCLI(context.Background(), full, &out, &errb, fakeFactory(provider)) + return code, out.String(), errb.String() +} + +func TestEvalGateFailsBelowThreshold(t *testing.T) { + suite := writeSuite(t, twoCaseSuite) + store := t.TempDir() + + // One of two cases passes; requiring 100% must exit non-zero. + code, stdout, stderr := runEval(t, fixedProvider{text: "hello!"}, + suite, "--store", store, "--min-pass-rate", "1") + if code != 1 { + t.Fatalf("exit code = %d, want 1 (stderr: %s)", code, stderr) + } + if !strings.Contains(stdout, "[PASS] ok") || !strings.Contains(stdout, "[FAIL] fail") { + t.Errorf("report missing per-case marks:\n%s", stdout) + } + if !strings.Contains(stderr, "gate failed") { + t.Errorf("expected a gate-failed message on stderr, got: %s", stderr) + } +} + +func TestEvalGatePassesAtRelaxedThreshold(t *testing.T) { + suite := writeSuite(t, twoCaseSuite) + code, stdout, stderr := runEval(t, fixedProvider{text: "hello!"}, + suite, "--store", t.TempDir(), "--min-pass-rate", "0.5") + if code != 0 { + t.Fatalf("exit code = %d, want 0 (stderr: %s)", code, stderr) + } + if !strings.Contains(stdout, "1/2 passed") { + t.Errorf("report missing totals line:\n%s", stdout) + } +} + +func TestEvalJSONOutput(t *testing.T) { + suite := writeSuite(t, twoCaseSuite) + code, stdout, _ := runEval(t, fixedProvider{text: "hello!"}, + suite, "--store", t.TempDir(), "--min-pass-rate", "0", "--json") + if code != 0 { + t.Fatalf("exit code = %d, want 0", code) + } + if !strings.Contains(stdout, `"suite": "smoke"`) || !strings.Contains(stdout, `"pass_rate"`) { + t.Errorf("expected JSON report, got:\n%s", stdout) + } +} + +func TestEvalRejectsMissingSuiteArg(t *testing.T) { + code, _, stderr := runEval(t, fixedProvider{text: "x"}) + if code != 1 { + t.Fatalf("exit code = %d, want 1", code) + } + if !strings.Contains(stderr, "usage: glue eval") { + t.Errorf("expected usage error, got: %s", stderr) + } +} diff --git a/cmd/glue/main.go b/cmd/glue/main.go index a4e7a2a..872add8 100644 --- a/cmd/glue/main.go +++ b/cmd/glue/main.go @@ -133,6 +133,12 @@ func runCLIWithDeps(ctx context.Context, args []string, stdin io.Reader, stdout fmt.Fprintln(stderr, err) } return code + case "eval": + if err := evalCommand(ctx, args[1:], stdout, stderr, newProvider); err != nil { + fmt.Fprintln(stderr, err) + return 1 + } + return 0 default: fmt.Fprintf(stderr, "unknown command %q\n\n", args[0]) printUsage(stderr) @@ -245,6 +251,7 @@ func printUsage(w io.Writer) { glue run --prompt [--provider ] [--id ] [--model ] [--store ] [--work ] [--coding] [--env ] glue goal "" [--provider ] [--model ] [--store ] [--work ] [--coding] [--yolo] [--worktree] [--max-iterations ] [--budget ] [--env ] glue goal --resume [] | --list [--store ] + glue eval [--provider ] [--model ] [--judge-model ] [--coding] [--work ] [--min-pass-rate <0..1>] [--parallel ] [--json] [--env ] glue serve [--provider ] [--listen 127.0.0.1:0] [--metadata ] [--model ] [--store ] [--work ] [--coding] [--env ] glue connect --prompt [--id ] [--metadata ] [--base-url ] [--token ] glue connect --skill [--arg key=value] [--id ] [--metadata ] [--base-url ] [--token ] @@ -267,6 +274,7 @@ func printUsage(w io.Writer) { Commands: run Run a local agent on any registered provider, optionally with coding tools. goal Run an autonomous goal loop headlessly (plan → make → verify until done or a guardrail trips); exit code reflects the outcome. + eval Run a declarative eval suite (JSON) against an agent and gate on the pass rate; exits non-zero when the gate fails. serve Start a local HTTP+SSE daemon for Glue sessions, optionally with coding tools. connect Start a daemon prompt/skill run, or inspect daemon status/tools/skills/roles/MCP/recall surfaces. version Print the binary's version, git revision, and Go toolchain (also: --version, -v). diff --git a/docs/adr/0019-evals-harness.md b/docs/adr/0019-evals-harness.md new file mode 100644 index 0000000..3986cd1 --- /dev/null +++ b/docs/adr/0019-evals-harness.md @@ -0,0 +1,84 @@ +# ADR-0019: Evals-as-Gates Harness + +## Status + +Accepted (2026-06-18). + +## Context + +The v1.13.0 harness work (edit repair ladder, retry/overflow recovery, +compaction, guardrails, capability registry) shipped real behavior +changes to the agent loop with no automated way to prove any of them +helped — or to catch a regression in the next change. The only feedback +loop was dogfooding by hand. A prompt tweak, a model swap, or a tool +description edit could quietly make the coding agent worse and nothing +would fail. + +Research into Vercel's **Eve** framework (June 2026) flagged its +**evals** as the second clean borrow (sibling to tracing, ADR-0018): +scored test suites that run an agent against cases and gate deploys in +CI. glue had nothing equivalent. + +## Decision + +Add an `evals/` package — a Go-native eval harness — plus a `glue eval` +command that runs a declarative suite and gates on the aggregate pass +rate. + +Core model: + +- A **`Case`** is a prompt plus per-case prompt options and a list of + **`Scorer`s**. A **`Suite`** is named cases with a shared pass + threshold. A **`Runner`** runs each case in its own session against a + `*glue.Agent` (optionally concurrently) and returns a **`Report`**. +- A **`Scorer`** grades a `Result` on a 0..1 scale. A case passes when it + ran without error and every scorer meets the threshold + (`DefaultPassThreshold` = 0.5, so 1/0 deterministic scorers and graded + judge scores share one cutoff). +- **`Report.Gate(minPassRate)`** returns an error when the pass rate is + too low; `glue eval` turns that into a non-zero exit so CI fails. + +Built-in scorers cover the two shapes that matter: + +- **Deterministic**: `Contains`, `NotContains`, `Regex`, `Equals`, and + `Command` (runs a shell command in a directory, passing on exit 0). + `Command` is the coding-agent gate — let the agent edit files, then run + the build/tests and score the outcome. +- **`Judge`** (LLM-as-judge): grades a response against a rubric via an + independent grading agent, for open-ended quality deterministic checks + can't capture. + +Key choices: + +- **Go-native, not a TS/DSL runtime.** glue agents are compiled Go + packages (ADR-0012); suites are too, so cases and scorers live next to + the agent and run under `go test`. A declarative **`SuiteSpec`** + (JSON) covers the built-in scorers for data-file suites and powers + `glue eval`; it rejects unknown fields and scorer types so a typo is a + loud error, never a silently-skipped check. +- **Library independent of the binary.** The `evals` package depends only + on `glue`; the `glue eval` command is a thin front end that builds the + agent-under-test (optionally with coding tools, auto-approving + permission) and a separate, tool-less **judge agent** so an + LLM-as-judge never grades its own work. +- **Errors fail loudly.** A scorer that errors (bad regex, judge call + failure) fails the case as a harness problem rather than scoring zero + silently; a low score is expressed as a `Value` with a `Reason`, not an + error. + +## Consequences + +- A prompt/model/tool change can be regression-tested: + `glue eval suite.json --min-pass-rate 0.9` exits non-zero in CI when + the agent degrades. Suites can mix deterministic gates (build passes, + no banned phrases) with judged quality. +- The harness runs real agent turns, so a suite costs provider tokens and + wall-clock; it is opt-in (a command / a test), never on the default + path. +- `Command` scorers and `--coding` eval runs execute tools and shell + commands with auto-approval — they are meant for a sandbox, feature + branch, or worktree, like `glue goal --yolo`. This is documented, not + enforced. +- Out of scope for now: remote/deployed eval runs, dataset management, + and per-case sandbox isolation. The `Runner` takes any `*glue.Agent`, + so a host can supply a worktree-rooted one. diff --git a/docs/evals.md b/docs/evals.md new file mode 100644 index 0000000..d6b77aa --- /dev/null +++ b/docs/evals.md @@ -0,0 +1,107 @@ +# Evals: scoring agents and gating CI + +The `evals` package runs a glue agent against a suite of cases, scores the +results, and reports a pass rate you can gate CI on. Use it to prove a +prompt/model/tool change actually improved behavior — and to catch +regressions before they ship. + +See [ADR-0019](adr/0019-evals-harness.md) for the design. + +## Concepts + +- **Case** — a prompt plus per-case options and a list of scorers. +- **Scorer** — grades a result on a 0..1 scale. +- **Suite** — named cases with a shared pass threshold (default 0.5). +- **Runner** — runs each case in its own session against a `*glue.Agent`. +- **Report** — per-case + aggregate results; `Gate(minPassRate)` returns + an error (→ non-zero exit) when the pass rate is too low. + +A case **passes** when it ran without error and **every** scorer meets the +threshold. + +### Built-in scorers + +| Scorer | Passes when | +| --- | --- | +| `Contains(subs…)` | response contains every substring (case-insensitive) | +| `NotContains(subs…)` | response contains none of the substrings | +| `Regex(pattern)` | response matches the pattern | +| `Equals(text)` | response equals the text (whitespace-trimmed) | +| `Command(dir, name, args…)` | the command exits 0 — the coding gate | +| `Judge{Agent, Rubric}` | an LLM grades the response ≥ threshold | +| `ScorerFunc(name, fn)` | your custom function returns a passing score | + +## Running a suite from the CLI + +Write a suite as JSON (see [`examples/evals/smoke.json`](../examples/evals/smoke.json)): + +```json +{ + "name": "smoke", + "threshold": 0.5, + "cases": [ + { "name": "greets", "prompt": "Greet the user.", + "scorers": [{ "type": "regex", "pattern": "(?i)hello|hi" }] }, + { "name": "math", "prompt": "What is 17 + 25?", + "scorers": [{ "type": "contains", "values": ["42"] }] }, + { "name": "quality", "prompt": "Explain recursion to a beginner.", + "scorers": [{ "type": "judge", "rubric": "Mentions a base case and is correct." }] } + ] +} +``` + +Run it and gate on the pass rate: + +```sh +glue eval examples/evals/smoke.json --provider gemini --min-pass-rate 0.9 +echo "exit: $?" # non-zero if the gate fails +``` + +Useful flags: `--model` / `--judge-model`, `--coding` (give the agent the +local coding tools), `--work ` (root for coding tools and `command` +scorers), `--parallel `, `--json` (machine-readable report), `--env`. + +> Eval runs are unattended and auto-approve side-effecting tools (like +> `glue goal --yolo`). Run `--coding` suites and `command` scorers on a +> feature branch, worktree, or sandbox. + +### Coding gate example + +Let the agent edit code, then score on whether the build/tests pass: + +```json +{ "name": "fixes-build", "prompt": "Make `go build ./...` pass.", + "scorers": [{ "type": "command", "command": "go", "args": ["build", "./..."], "dir": "." }] } +``` + +## Running a suite from Go + +```go +suite := evals.Suite{ + Name: "smoke", + Cases: []evals.Case{ + {Name: "greets", Prompt: "Greet the user.", + Scorers: []evals.Scorer{evals.Regex("(?i)hello|hi")}}, + {Name: "math", Prompt: "What is 17 + 25?", + Scorers: []evals.Scorer{evals.Contains("42")}}, + }, +} + +report, err := evals.Runner{Agent: agent, Parallelism: 4}.Run(ctx, suite) +if err != nil { + log.Fatal(err) +} +report.WriteText(os.Stdout) +if err := report.Gate(1.0); err != nil { // require every case to pass + log.Fatal(err) +} +``` + +This drops straight into a `go test` so suites run in the same CI as the +unit tests. For an LLM-as-judge scorer, give it a separate, tool-less +agent so it never grades its own work: + +```go +judge := glue.NewAgent(glue.AgentOptions{Provider: provider, Model: "strong-model"}) +scorer := evals.Judge{Agent: judge, Rubric: "Correct, concise, beginner-friendly."} +``` diff --git a/evals/doc.go b/evals/doc.go new file mode 100644 index 0000000..4972da0 --- /dev/null +++ b/evals/doc.go @@ -0,0 +1,34 @@ +// Package evals is a Go-native eval harness for glue agents: define a +// suite of cases, run them against an agent, score the results, and gate +// CI on the aggregate pass rate. +// +// It borrows Eve's evals-as-gates idea but stays compiled and dependency +// free, matching how glue agents are built (ADR-0012): a [Suite] is +// ordinary Go, so cases, scorers, and thresholds live next to the agent +// they exercise and run under `go test` or a `glue eval` command. +// +// A minimal suite: +// +// suite := evals.Suite{ +// Name: "smoke", +// Cases: []evals.Case{{ +// Name: "greets", +// Prompt: "Say hello to the user.", +// Scorers: []evals.Scorer{evals.Contains("hello")}, +// }}, +// } +// report, err := evals.Runner{Agent: agent}.Run(ctx, suite) +// if err != nil { +// log.Fatal(err) +// } +// report.WriteText(os.Stdout) +// if err := report.Gate(1.0); err != nil { // require every case to pass +// os.Exit(1) +// } +// +// Scorers grade a [Result] on a 0..1 scale. The package ships +// deterministic scorers ([Contains], [NotContains], [Regex], [Equals], +// [Command], [ScorerFunc]) and an LLM-as-judge scorer ([Judge]). A case +// passes when it ran without error and every scorer meets the suite's +// pass threshold. +package evals diff --git a/evals/eval.go b/evals/eval.go new file mode 100644 index 0000000..064c6aa --- /dev/null +++ b/evals/eval.go @@ -0,0 +1,223 @@ +package evals + +import ( + "context" + "fmt" + "sync" + + "github.com/erain/glue" +) + +// DefaultPassThreshold is the minimum scorer value (0..1) counted as a +// pass when a [Suite] does not set its own PassThreshold. Built-in +// deterministic scorers report 1 or 0, so the midpoint cleanly separates +// pass from fail while still admitting graded [Judge] scores. +const DefaultPassThreshold = 0.5 + +// Case is one eval: a prompt run against an agent, graded by scorers. +type Case struct { + // Name identifies the case in reports and session ids. Required and + // unique within a suite. + Name string + + // Prompt is the user message sent to the agent. + Prompt string + + // Options are per-case prompt options (model, tools, permission, + // system prompt, …). They let one suite exercise several + // configurations without separate agents. + Options []glue.PromptOption + + // Scorers grade the result. A case with no scorers passes as long as + // the run itself succeeds (useful as a smoke test). + Scorers []Scorer +} + +// Score is one scorer's grade for a case. +type Score struct { + // Scorer is the grading scorer's name. + Scorer string + // Value is the grade in [0,1]; it is clamped to that range. + Value float64 + // Reason is a short human explanation, shown in reports. + Reason string +} + +// Passed reports whether this score meets threshold. +func (s Score) Passed(threshold float64) bool { return s.Value >= threshold } + +// Result is what a [Case] produced, handed to each [Scorer]. +type Result struct { + // Case is the case that produced this result. + Case Case + // Response is the agent's prompt result (text + transcript). + Response glue.PromptResult + // Err is a non-nil run error (provider failure, cancellation). When + // set, scorers are skipped and the case fails. + Err error +} + +// Text is the agent's final assistant text, the common scoring target. +func (r Result) Text() string { return r.Response.Text } + +// Scorer grades a [Result] on a 0..1 scale. +type Scorer interface { + // Name identifies the scorer in reports. + Name() string + // Score grades the result. Returning an error fails the case; use a + // zero Value with a Reason instead when a low score is the intended + // signal rather than a harness failure. + Score(ctx context.Context, r Result) (Score, error) +} + +// Suite is a named set of cases with a shared pass threshold. +type Suite struct { + // Name labels the suite in reports and session ids. + Name string + // Cases are run in order (or concurrently; see [Runner.Parallelism]). + Cases []Case + // PassThreshold is the minimum scorer value counted as a pass. Zero + // uses [DefaultPassThreshold]. + PassThreshold float64 +} + +func (s Suite) threshold() float64 { + if s.PassThreshold <= 0 { + return DefaultPassThreshold + } + return s.PassThreshold +} + +// Runner executes a [Suite] against an agent. +type Runner struct { + // Agent runs each case. Required. Prefer an in-memory agent (no + // Store) or accept that each case persists under a distinct + // "eval//" session id. + Agent *glue.Agent + + // Parallelism caps concurrently-running cases. Zero or one runs + // cases sequentially in suite order. + Parallelism int +} + +// Run executes every case and returns a [Report]. It returns an error +// only for a misconfigured runner; a case that errors at runtime is +// captured in its [CaseReport] (Passed=false) rather than aborting the +// suite. +func (r Runner) Run(ctx context.Context, suite Suite) (Report, error) { + if r.Agent == nil { + return Report{}, fmt.Errorf("evals: runner has no agent") + } + if err := suite.validate(); err != nil { + return Report{}, err + } + + threshold := suite.threshold() + reports := make([]CaseReport, len(suite.Cases)) + + parallelism := r.Parallelism + if parallelism < 1 { + parallelism = 1 + } + sem := make(chan struct{}, parallelism) + var wg sync.WaitGroup + for i, c := range suite.Cases { + if err := ctx.Err(); err != nil { + // Context cancelled mid-suite: record the remaining cases as + // errored so the report stays complete and the gate fails. + reports[i] = CaseReport{Name: c.Name, Err: err} + continue + } + wg.Add(1) + sem <- struct{}{} + go func(i int, c Case) { + defer wg.Done() + defer func() { <-sem }() + reports[i] = r.runCase(ctx, suite, c, threshold) + }(i, c) + } + wg.Wait() + + return Report{ + Suite: suite.Name, + PassThreshold: threshold, + Cases: reports, + }, nil +} + +func (r Runner) runCase(ctx context.Context, suite Suite, c Case, threshold float64) CaseReport { + report := CaseReport{Name: c.Name} + + sess, err := r.Agent.Session(ctx, sessionID(suite.Name, c.Name)) + if err != nil { + report.Err = fmt.Errorf("open session: %w", err) + return report + } + resp, runErr := sess.Prompt(ctx, c.Prompt, c.Options...) + result := Result{Case: c, Response: resp, Err: runErr} + if runErr != nil { + report.Err = runErr + return report + } + + report.Passed = true // a case with no scorers passes on a clean run + for _, scorer := range c.Scorers { + score, err := scorer.Score(ctx, result) + if err != nil { + report.Err = fmt.Errorf("scorer %q: %w", scorer.Name(), err) + report.Passed = false + return report + } + score.Scorer = scorer.Name() + score.Value = clamp01(score.Value) + report.Scores = append(report.Scores, score) + if !score.Passed(threshold) { + report.Passed = false + } + } + report.Score = meanScore(report.Scores) + return report +} + +func (s Suite) validate() error { + if s.Name == "" { + return fmt.Errorf("evals: suite has no name") + } + seen := make(map[string]struct{}, len(s.Cases)) + for i, c := range s.Cases { + if c.Name == "" { + return fmt.Errorf("evals: case %d has no name", i) + } + if _, dup := seen[c.Name]; dup { + return fmt.Errorf("evals: duplicate case name %q", c.Name) + } + seen[c.Name] = struct{}{} + } + return nil +} + +func sessionID(suite, name string) string { + return fmt.Sprintf("eval/%s/%s", suite, name) +} + +func clamp01(v float64) float64 { + switch { + case v < 0: + return 0 + case v > 1: + return 1 + default: + return v + } +} + +func meanScore(scores []Score) float64 { + if len(scores) == 0 { + return 1 + } + var sum float64 + for _, s := range scores { + sum += s.Value + } + return sum / float64(len(scores)) +} diff --git a/evals/evals_test.go b/evals/evals_test.go new file mode 100644 index 0000000..df642d9 --- /dev/null +++ b/evals/evals_test.go @@ -0,0 +1,213 @@ +package evals + +import ( + "context" + "strings" + "testing" + + "github.com/erain/glue" +) + +// scriptedProvider answers each prompt by looking up the last user +// message in a canned reply table; an unmatched prompt echoes a default. +// It implements glue.Provider without any network dependency. +type scriptedProvider struct { + replies map[string]string + def string +} + +func (p *scriptedProvider) Stream(_ context.Context, req glue.ProviderRequest) (<-chan glue.ProviderEvent, error) { + text := p.def + for i := len(req.Messages) - 1; i >= 0; i-- { + if req.Messages[i].Role != glue.MessageRoleUser { + continue + } + // Pick the longest (most specific) matching key so a reply is + // deterministic even when one prompt embeds another (e.g. a judge + // prompt that quotes the case prompt). + key := userText(req.Messages[i]) + bestLen := -1 + for prompt, reply := range p.replies { + if strings.Contains(key, prompt) && len(prompt) > bestLen { + text = reply + bestLen = len(prompt) + } + } + break + } + + ch := make(chan glue.ProviderEvent, 3) + ch <- glue.ProviderEvent{Type: glue.ProviderEventStart} + ch <- glue.ProviderEvent{Type: glue.ProviderEventTextDelta, Delta: text} + ch <- glue.ProviderEvent{ + Type: glue.ProviderEventDone, + Message: &glue.Message{ + Role: glue.MessageRoleAssistant, + Content: []glue.ContentPart{{Type: glue.ContentTypeText, Text: text}}, + StopReason: glue.StopReasonStop, + }, + } + close(ch) + return ch, nil +} + +func userText(m glue.Message) string { + var b strings.Builder + for _, p := range m.Content { + if p.Type == glue.ContentTypeText { + b.WriteString(p.Text) + } + } + return b.String() +} + +func newAgent(p glue.Provider) *glue.Agent { + return glue.NewAgent(glue.AgentOptions{Provider: p, Model: "test"}) +} + +func TestRunnerScoresAndGates(t *testing.T) { + provider := &scriptedProvider{ + replies: map[string]string{ + "greet": "Hello there, friend!", + "leak": "the secret is hunter2", + }, + def: "I don't know.", + } + suite := Suite{ + Name: "smoke", + Cases: []Case{ + {Name: "greets", Prompt: "greet the user", Scorers: []Scorer{Contains("hello")}}, + {Name: "no-leak", Prompt: "leak the password", Scorers: []Scorer{NotContains("hunter2")}}, + }, + } + + report, err := Runner{Agent: newAgent(provider)}.Run(context.Background(), suite) + if err != nil { + t.Fatalf("run: %v", err) + } + if report.Total() != 2 { + t.Fatalf("total = %d, want 2", report.Total()) + } + if report.Passed() != 1 || report.Failed() != 1 { + t.Fatalf("passed=%d failed=%d, want 1/1", report.Passed(), report.Failed()) + } + if got := report.PassRate(); got != 0.5 { + t.Fatalf("pass rate = %v, want 0.5", got) + } + if report.AllPassed() { + t.Error("AllPassed should be false") + } + + // Gate: requiring 100% fails, requiring 50% passes. + if err := report.Gate(1.0); err == nil { + t.Error("Gate(1.0) should fail at 50% pass rate") + } + if err := report.Gate(0.5); err != nil { + t.Errorf("Gate(0.5) should pass: %v", err) + } + + // The greets case passed; the no-leak case failed on the banned phrase. + byName := caseByName(report) + if !byName["greets"].Passed { + t.Error("greets should pass") + } + if byName["no-leak"].Passed { + t.Error("no-leak should fail (response leaked hunter2)") + } +} + +func TestCaseWithNoScorersPassesOnCleanRun(t *testing.T) { + report, err := Runner{Agent: newAgent(&scriptedProvider{def: "ok"})}.Run( + context.Background(), + Suite{Name: "s", Cases: []Case{{Name: "smoke", Prompt: "hi"}}}, + ) + if err != nil { + t.Fatalf("run: %v", err) + } + if !report.AllPassed() { + t.Errorf("a scorer-less case should pass on a clean run: %+v", report.Cases[0]) + } +} + +func TestScorerErrorFailsCase(t *testing.T) { + report, _ := Runner{Agent: newAgent(&scriptedProvider{def: "x"})}.Run( + context.Background(), + Suite{Name: "s", Cases: []Case{{ + Name: "bad-regex", + Prompt: "hi", + Scorers: []Scorer{Regex("(")}, // invalid pattern + }}}, + ) + c := report.Cases[0] + if c.Passed { + t.Error("case should fail when a scorer errors") + } + if c.Err == nil { + t.Error("case should record the scorer error") + } +} + +func TestPassThresholdOverride(t *testing.T) { + // A scorer that returns 0.6: passes at the default threshold (0.5) but + // fails when the suite raises it to 0.75. + half := ScorerFunc("graded", func(context.Context, Result) (Score, error) { + return Score{Value: 0.6, Reason: "partial"}, nil + }) + mk := func(threshold float64) Report { + rep, _ := Runner{Agent: newAgent(&scriptedProvider{def: "x"})}.Run( + context.Background(), + Suite{Name: "s", PassThreshold: threshold, Cases: []Case{{Name: "c", Prompt: "hi", Scorers: []Scorer{half}}}}, + ) + return rep + } + if !mk(0).AllPassed() { // 0 -> default 0.5; 0.6 >= 0.5 passes + t.Error("0.6 should pass at default threshold") + } + if mk(0.75).AllPassed() { + t.Error("0.6 should fail at threshold 0.75") + } +} + +func TestRunnerParallelism(t *testing.T) { + cases := make([]Case, 8) + for i := range cases { + cases[i] = Case{Name: string(rune('a' + i)), Prompt: "greet", Scorers: []Scorer{Contains("hi")}} + } + report, err := Runner{ + Agent: newAgent(&scriptedProvider{replies: map[string]string{"greet": "hi!"}, def: "?"}), + Parallelism: 4, + }.Run(context.Background(), Suite{Name: "p", Cases: cases}) + if err != nil { + t.Fatalf("run: %v", err) + } + if report.Passed() != 8 { + t.Errorf("passed = %d, want 8", report.Passed()) + } +} + +func TestValidateRejectsDuplicateAndUnnamed(t *testing.T) { + r := Runner{Agent: newAgent(&scriptedProvider{def: "x"})} + if _, err := r.Run(context.Background(), Suite{Name: "s", Cases: []Case{{Name: "a"}, {Name: "a"}}}); err == nil { + t.Error("expected duplicate-name error") + } + if _, err := r.Run(context.Background(), Suite{Name: "s", Cases: []Case{{Name: ""}}}); err == nil { + t.Error("expected empty-name error") + } + if _, err := r.Run(context.Background(), Suite{Name: "", Cases: nil}); err == nil { + t.Error("expected empty-suite-name error") + } +} + +func TestRunnerRequiresAgent(t *testing.T) { + if _, err := (Runner{}).Run(context.Background(), Suite{Name: "s"}); err == nil { + t.Error("expected error when runner has no agent") + } +} + +func caseByName(r Report) map[string]CaseReport { + out := make(map[string]CaseReport, len(r.Cases)) + for _, c := range r.Cases { + out[c.Name] = c + } + return out +} diff --git a/evals/judge.go b/evals/judge.go new file mode 100644 index 0000000..4088038 --- /dev/null +++ b/evals/judge.go @@ -0,0 +1,96 @@ +package evals + +import ( + "context" + "fmt" + "strings" + + "github.com/erain/glue" +) + +// Judge is an LLM-as-judge [Scorer]: it asks a grading agent to rate how +// well the response satisfies a rubric, on a 0..1 scale. Use it for +// open-ended quality that deterministic scorers can't capture (tone, +// correctness of prose, following multi-part instructions). +// +// The grading agent should be independent of the agent under test — +// typically a strong model with no tools — so the judge is not grading +// its own work. +type Judge struct { + // Agent grades the response. Required. + Agent *glue.Agent + // Model overrides the grading model. Empty uses the agent default. + Model string + // Rubric describes what a good response looks like. Required. + Rubric string + // Name overrides the scorer name in reports (default "judge"). + Name_ string +} + +// Name implements [Scorer]. +func (j Judge) Name() string { + if j.Name_ != "" { + return j.Name_ + } + return "judge" +} + +type judgeVerdict struct { + Score float64 `json:"score"` + Reason string `json:"reason"` +} + +var judgeSchema = map[string]any{ + "type": "object", + "properties": map[string]any{ + "score": map[string]any{"type": "number", "description": "quality from 0.0 (fails the rubric) to 1.0 (fully satisfies it)"}, + "reason": map[string]any{"type": "string", "description": "one or two sentences justifying the score"}, + }, + "required": []string{"score", "reason"}, +} + +// Score implements [Scorer]. A misconfigured judge (no agent or rubric) +// or a grading-call failure returns an error, failing the case as a +// harness problem rather than silently scoring zero. +func (j Judge) Score(ctx context.Context, r Result) (Score, error) { + if j.Agent == nil { + return Score{}, fmt.Errorf("judge: no grading agent") + } + if strings.TrimSpace(j.Rubric) == "" { + return Score{}, fmt.Errorf("judge: empty rubric") + } + + sess, err := j.Agent.Session(ctx, "eval-judge/"+r.Case.Name) + if err != nil { + return Score{}, fmt.Errorf("judge: open session: %w", err) + } + + opts := []glue.PromptOption{glue.WithJSONSchema(judgeSchema)} + if j.Model != "" { + opts = append(opts, glue.WithModel(j.Model)) + } + + var verdict judgeVerdict + if _, err := sess.PromptJSON(ctx, j.gradePrompt(r), &verdict, opts...); err != nil { + return Score{}, fmt.Errorf("judge: grading call: %w", err) + } + + return Score{ + Scorer: j.Name(), + Value: verdict.Score, + Reason: verdict.Reason, + }, nil +} + +func (j Judge) gradePrompt(r Result) string { + var b strings.Builder + b.WriteString("You are grading an AI agent's response. Score how well it satisfies the rubric.\n\n") + b.WriteString("RUBRIC:\n") + b.WriteString(strings.TrimSpace(j.Rubric)) + b.WriteString("\n\nThe user asked:\n") + b.WriteString(strings.TrimSpace(r.Case.Prompt)) + b.WriteString("\n\nThe agent responded:\n") + b.WriteString(strings.TrimSpace(r.Text())) + b.WriteString("\n\nReturn a score from 0.0 to 1.0 and a short reason. Be a strict grader: reserve scores above 0.8 for responses that clearly satisfy every part of the rubric.") + return b.String() +} diff --git a/evals/judge_test.go b/evals/judge_test.go new file mode 100644 index 0000000..4d5e1fd --- /dev/null +++ b/evals/judge_test.go @@ -0,0 +1,74 @@ +package evals + +import ( + "context" + "testing" + + "github.com/erain/glue" +) + +func TestJudgeScores(t *testing.T) { + // The judge's grading prompt opens with "You are grading an AI + // agent's response"; the scripted provider matches on that and + // returns the verdict JSON the judge parses. + provider := &scriptedProvider{ + replies: map[string]string{ + "grading an AI agent": `{"score": 0.9, "reason": "fully satisfies the rubric"}`, + }, + def: `{"score": 0.0, "reason": "no match"}`, + } + judge := Judge{Agent: newAgent(provider), Rubric: "The response must greet the user warmly."} + + got, err := judge.Score(context.Background(), Result{ + Case: Case{Name: "greets", Prompt: "greet the user"}, + Response: promptResult("Hello, so lovely to see you!"), + }) + if err != nil { + t.Fatalf("judge errored: %v", err) + } + if got.Value != 0.9 { + t.Errorf("score = %v, want 0.9", got.Value) + } + if got.Reason == "" { + t.Error("judge should carry a reason") + } + if got.Scorer != "judge" { + t.Errorf("scorer name = %q, want judge", got.Scorer) + } +} + +func TestJudgeAsScorerInSuite(t *testing.T) { + provider := &scriptedProvider{ + replies: map[string]string{ + "greet the user": "Hello, friend!", // agent-under-test reply + "grading an AI agent": `{"score": 0.95, "reason": "warm greeting"}`, // judge reply + }, + def: "?", + } + agent := newAgent(provider) + report, err := Runner{Agent: agent}.Run(context.Background(), Suite{ + Name: "judged", + Cases: []Case{{ + Name: "warm-greeting", + Prompt: "greet the user", + Scorers: []Scorer{Judge{Agent: agent, Rubric: "Greet the user warmly."}}, + }}, + }) + if err != nil { + t.Fatalf("run: %v", err) + } + if !report.AllPassed() { + t.Errorf("judged case should pass (0.95 >= 0.5): %+v", report.Cases[0]) + } +} + +func TestJudgeMisconfigured(t *testing.T) { + if _, err := (Judge{Rubric: "x"}).Score(context.Background(), Result{}); err == nil { + t.Error("expected error when judge has no agent") + } + if _, err := (Judge{Agent: newAgent(&scriptedProvider{def: "{}"})}).Score(context.Background(), Result{}); err == nil { + t.Error("expected error when judge has an empty rubric") + } +} + +func promptResult(text string) glue.PromptResult { return glue.PromptResult{Text: text} } diff --git a/evals/report.go b/evals/report.go new file mode 100644 index 0000000..a3724d8 --- /dev/null +++ b/evals/report.go @@ -0,0 +1,100 @@ +package evals + +import ( + "fmt" + "io" + "strings" +) + +// CaseReport is the outcome of one [Case]. +type CaseReport struct { + // Name is the case name. + Name string + // Passed is true when the run succeeded and every scorer met the + // suite threshold. + Passed bool + // Score is the mean scorer value in [0,1] (1 when there are no + // scorers). + Score float64 + // Scores holds each scorer's grade. + Scores []Score + // Err is set when the run or a scorer failed; the case did not pass. + Err error +} + +// Report aggregates a suite run. +type Report struct { + // Suite is the suite name. + Suite string + // PassThreshold is the per-scorer pass cutoff that was applied. + PassThreshold float64 + // Cases holds one entry per case, in suite order. + Cases []CaseReport +} + +// Total is the number of cases run. +func (r Report) Total() int { return len(r.Cases) } + +// Passed counts cases that passed. +func (r Report) Passed() int { + n := 0 + for _, c := range r.Cases { + if c.Passed { + n++ + } + } + return n +} + +// Failed counts cases that did not pass. +func (r Report) Failed() int { return r.Total() - r.Passed() } + +// PassRate is passed/total in [0,1]; an empty report rates 1. +func (r Report) PassRate() float64 { + if len(r.Cases) == 0 { + return 1 + } + return float64(r.Passed()) / float64(len(r.Cases)) +} + +// AllPassed reports whether every case passed. +func (r Report) AllPassed() bool { return r.Failed() == 0 } + +// Gate returns nil when the pass rate is at least minPassRate, otherwise a +// non-nil error describing the shortfall. CI callers exit non-zero on a +// non-nil result; pass 1.0 to require every case to pass. +func (r Report) Gate(minPassRate float64) error { + if got := r.PassRate(); got < minPassRate { + return fmt.Errorf("evals: suite %q gate failed: pass rate %.0f%% (%d/%d) below required %.0f%%", + r.Suite, got*100, r.Passed(), r.Total(), minPassRate*100) + } + return nil +} + +// WriteText writes a human-readable summary: one line per case plus a +// totals line. +func (r Report) WriteText(w io.Writer) { + fmt.Fprintf(w, "eval suite %q (threshold %.2f)\n", r.Suite, r.PassThreshold) + for _, c := range r.Cases { + mark := "PASS" + if !c.Passed { + mark = "FAIL" + } + fmt.Fprintf(w, " [%s] %s (score %.2f)\n", mark, c.Name, c.Score) + if c.Err != nil { + fmt.Fprintf(w, " error: %v\n", c.Err) + } + for _, s := range c.Scores { + smark := "ok" + if !s.Passed(r.PassThreshold) { + smark = "x" + } + line := fmt.Sprintf(" - %s: %.2f [%s]", s.Scorer, s.Value, smark) + if s.Reason != "" { + line += " — " + strings.TrimSpace(s.Reason) + } + fmt.Fprintln(w, line) + } + } + fmt.Fprintf(w, " %d/%d passed (%.0f%%)\n", r.Passed(), r.Total(), r.PassRate()*100) +} diff --git a/evals/scorers.go b/evals/scorers.go new file mode 100644 index 0000000..ee1db5e --- /dev/null +++ b/evals/scorers.go @@ -0,0 +1,144 @@ +package evals + +import ( + "context" + "fmt" + "os/exec" + "regexp" + "strings" +) + +// scorerFunc adapts a plain function to the [Scorer] interface. +type scorerFunc struct { + name string + fn func(ctx context.Context, r Result) (Score, error) +} + +func (s scorerFunc) Name() string { return s.name } +func (s scorerFunc) Score(ctx context.Context, r Result) (Score, error) { + return s.fn(ctx, r) +} + +// ScorerFunc builds a [Scorer] from a name and grading function. Use it +// for one-off, suite-specific checks without declaring a type. +func ScorerFunc(name string, fn func(ctx context.Context, r Result) (Score, error)) Scorer { + return scorerFunc{name: name, fn: fn} +} + +// boolScore maps a pass/fail to a 1/0 Score with a reason. +func boolScore(scorer string, pass bool, passReason, failReason string) Score { + if pass { + return Score{Scorer: scorer, Value: 1, Reason: passReason} + } + return Score{Scorer: scorer, Value: 0, Reason: failReason} +} + +// Contains passes (1) when the response text contains every substring +// (case-insensitive), else fails (0). Zero substrings always passes. +func Contains(substrs ...string) Scorer { + return ScorerFunc("contains", func(_ context.Context, r Result) (Score, error) { + text := strings.ToLower(r.Text()) + for _, sub := range substrs { + if !strings.Contains(text, strings.ToLower(sub)) { + return boolScore("contains", false, "", fmt.Sprintf("missing %q", sub)), nil + } + } + return boolScore("contains", true, "all substrings present", ""), nil + }) +} + +// NotContains passes (1) when the response text contains none of the +// substrings (case-insensitive), else fails (0). Useful for banned +// phrases or leaked secrets. +func NotContains(substrs ...string) Scorer { + return ScorerFunc("not_contains", func(_ context.Context, r Result) (Score, error) { + text := strings.ToLower(r.Text()) + for _, sub := range substrs { + if strings.Contains(text, strings.ToLower(sub)) { + return boolScore("not_contains", false, "", fmt.Sprintf("found banned %q", sub)), nil + } + } + return boolScore("not_contains", true, "no banned substrings", ""), nil + }) +} + +// Regex passes (1) when the response text matches pattern. A bad pattern +// fails the case with a harness error. +func Regex(pattern string) Scorer { + return ScorerFunc("regex", func(_ context.Context, r Result) (Score, error) { + re, err := regexp.Compile(pattern) + if err != nil { + return Score{}, fmt.Errorf("compile %q: %w", pattern, err) + } + ok := re.MatchString(r.Text()) + return boolScore("regex", ok, "matched "+pattern, "no match for "+pattern), nil + }) +} + +// Equals passes (1) when the response text equals expected after trimming +// surrounding whitespace on both sides. +func Equals(expected string) Scorer { + return ScorerFunc("equals", func(_ context.Context, r Result) (Score, error) { + got := strings.TrimSpace(r.Text()) + want := strings.TrimSpace(expected) + return boolScore("equals", got == want, "exact match", "text did not equal expected"), nil + }) +} + +// CommandScorer runs a shell command and passes (1) on exit code 0. It is +// the coding-agent gate: after the agent edits files, run the build or +// tests and score on the outcome. +type CommandScorer struct { + // Name overrides the scorer name in reports (default "command"). + Name_ string + // Command is the program to run. + Command string + // Args are the program arguments. + Args []string + // Dir is the working directory; empty uses the current directory. + Dir string +} + +// Command builds a [CommandScorer] for `name args...` in dir. +func Command(dir, name string, args ...string) Scorer { + return CommandScorer{Command: name, Args: args, Dir: dir} +} + +// Name implements [Scorer]. +func (c CommandScorer) Name() string { + if c.Name_ != "" { + return c.Name_ + } + return "command" +} + +// Score implements [Scorer]: it runs the command and grades on its exit +// status. The combined output is truncated into the reason for context. +func (c CommandScorer) Score(ctx context.Context, _ Result) (Score, error) { + cmd := exec.CommandContext(ctx, c.Command, c.Args...) + cmd.Dir = c.Dir + out, err := cmd.CombinedOutput() + label := strings.TrimSpace(c.Command + " " + strings.Join(c.Args, " ")) + if err != nil { + return Score{ + Scorer: c.Name(), + Value: 0, + Reason: fmt.Sprintf("%s failed: %v%s", label, err, outputTail(out)), + }, nil + } + return Score{Scorer: c.Name(), Value: 1, Reason: label + " ok"}, nil +} + +// outputTail returns a short, single-line-prefixed tail of command output +// for inclusion in a score reason. +func outputTail(out []byte) string { + s := strings.TrimSpace(string(out)) + if s == "" { + return "" + } + const max = 200 + if len(s) > max { + s = "…" + s[len(s)-max:] + } + return "\n " + strings.ReplaceAll(s, "\n", "\n ") +} diff --git a/evals/scorers_test.go b/evals/scorers_test.go new file mode 100644 index 0000000..ddd3482 --- /dev/null +++ b/evals/scorers_test.go @@ -0,0 +1,74 @@ +package evals + +import ( + "context" + "testing" + + "github.com/erain/glue" +) + +func score(t *testing.T, s Scorer, text string) Score { + t.Helper() + out, err := s.Score(context.Background(), Result{Response: glue.PromptResult{Text: text}}) + if err != nil { + t.Fatalf("scorer %s errored: %v", s.Name(), err) + } + return out +} + +func TestContainsScorer(t *testing.T) { + s := Contains("Hello", "world") + if v := score(t, s, "well, HELLO WORLD!").Value; v != 1 { + t.Errorf("case-insensitive contains should pass, got %v", v) + } + if v := score(t, s, "hello there").Value; v != 0 { + t.Errorf("missing substring should fail, got %v", v) + } +} + +func TestNotContainsScorer(t *testing.T) { + s := NotContains("secret") + if v := score(t, s, "all clear").Value; v != 1 { + t.Errorf("clean text should pass, got %v", v) + } + if v := score(t, s, "the SECRET is out").Value; v != 0 { + t.Errorf("banned text should fail, got %v", v) + } +} + +func TestEqualsScorer(t *testing.T) { + s := Equals(" done ") + if v := score(t, s, "done").Value; v != 1 { + t.Errorf("trimmed equality should pass, got %v", v) + } + if v := score(t, s, "not done").Value; v != 0 { + t.Errorf("inequality should fail, got %v", v) + } +} + +func TestRegexScorer(t *testing.T) { + if v := score(t, Regex(`\d{3}-\d{4}`), "call 555-1234 now").Value; v != 1 { + t.Errorf("regex should match, got %v", v) + } + if _, err := Regex("(").Score(context.Background(), Result{}); err == nil { + t.Error("invalid regex should error") + } +} + +func TestCommandScorer(t *testing.T) { + pass, err := Command("", "true").Score(context.Background(), Result{}) + if err != nil { + t.Fatalf("command scorer errored: %v", err) + } + if pass.Value != 1 { + t.Errorf("`true` should score 1, got %v", pass.Value) + } + + fail, err := Command("", "false").Score(context.Background(), Result{}) + if err != nil { + t.Fatalf("command scorer errored: %v", err) + } + if fail.Value != 0 { + t.Errorf("`false` should score 0, got %v", fail.Value) + } +} diff --git a/evals/spec.go b/evals/spec.go new file mode 100644 index 0000000..8bcbd34 --- /dev/null +++ b/evals/spec.go @@ -0,0 +1,134 @@ +package evals + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/erain/glue" +) + +// SuiteSpec is the JSON-friendly, declarative form of a [Suite]. It lets a +// suite live in a data file (run by `glue eval`) instead of Go, at the +// cost of only the built-in scorer types. +// +// Example: +// +// { +// "name": "smoke", +// "threshold": 0.5, +// "cases": [ +// { +// "name": "greets", +// "prompt": "Say hello to the user.", +// "scorers": [{"type": "contains", "values": ["hello"]}] +// } +// ] +// } +type SuiteSpec struct { + Name string `json:"name"` + Threshold float64 `json:"threshold,omitempty"` + Cases []CaseSpec `json:"cases"` +} + +// CaseSpec is the declarative form of a [Case]. +type CaseSpec struct { + Name string `json:"name"` + Prompt string `json:"prompt"` + Model string `json:"model,omitempty"` + Scorers []ScorerSpec `json:"scorers,omitempty"` +} + +// ScorerSpec is the declarative form of a [Scorer]. Type selects which +// fields are read: +// +// contains / not_contains → values +// regex → pattern +// equals → value +// command → command, args, dir +// judge → rubric, model +type ScorerSpec struct { + Type string `json:"type"` + Values []string `json:"values,omitempty"` + Pattern string `json:"pattern,omitempty"` + Value string `json:"value,omitempty"` + Command string `json:"command,omitempty"` + Args []string `json:"args,omitempty"` + Dir string `json:"dir,omitempty"` + Rubric string `json:"rubric,omitempty"` + Model string `json:"model,omitempty"` +} + +// BuildOptions supplies dependencies a declarative suite cannot carry +// itself. +type BuildOptions struct { + // JudgeAgent grades "judge" scorers. Required only when the suite + // uses them; building fails otherwise. + JudgeAgent *glue.Agent +} + +// ParseSuite decodes a [SuiteSpec] from JSON and builds a runnable +// [Suite]. It rejects unknown scorer types so a typo in a data file is a +// loud error rather than a silently-skipped check. +func ParseSuite(data []byte, opts BuildOptions) (Suite, error) { + var spec SuiteSpec + dec := json.NewDecoder(bytes.NewReader(data)) + dec.DisallowUnknownFields() + if err := dec.Decode(&spec); err != nil { + return Suite{}, fmt.Errorf("evals: decode suite: %w", err) + } + return spec.Build(opts) +} + +// Build converts a parsed spec into a runnable [Suite]. +func (spec SuiteSpec) Build(opts BuildOptions) (Suite, error) { + suite := Suite{Name: spec.Name, PassThreshold: spec.Threshold} + for i, cs := range spec.Cases { + c := Case{Name: cs.Name, Prompt: cs.Prompt} + if cs.Model != "" { + c.Options = append(c.Options, glue.WithModel(cs.Model)) + } + for j, ss := range cs.Scorers { + scorer, err := ss.build(opts) + if err != nil { + return Suite{}, fmt.Errorf("case %d (%q) scorer %d: %w", i, cs.Name, j, err) + } + c.Scorers = append(c.Scorers, scorer) + } + suite.Cases = append(suite.Cases, c) + } + if err := suite.validate(); err != nil { + return Suite{}, err + } + return suite, nil +} + +func (ss ScorerSpec) build(opts BuildOptions) (Scorer, error) { + switch ss.Type { + case "contains": + return Contains(ss.Values...), nil + case "not_contains": + return NotContains(ss.Values...), nil + case "regex": + return Regex(ss.Pattern), nil + case "equals": + return Equals(ss.Value), nil + case "command": + if ss.Command == "" { + return nil, fmt.Errorf("command scorer needs a command") + } + return Command(ss.Dir, ss.Command, ss.Args...), nil + case "judge": + if opts.JudgeAgent == nil { + return nil, fmt.Errorf("judge scorer needs a judge agent (none configured)") + } + if ss.Rubric == "" { + return nil, fmt.Errorf("judge scorer needs a rubric") + } + return Judge{Agent: opts.JudgeAgent, Rubric: ss.Rubric, Model: ss.Model}, nil + case "": + return nil, fmt.Errorf("scorer has no type") + default: + return nil, fmt.Errorf("unknown scorer type %q", ss.Type) + } +} diff --git a/evals/spec_test.go b/evals/spec_test.go new file mode 100644 index 0000000..8c98130 --- /dev/null +++ b/evals/spec_test.go @@ -0,0 +1,94 @@ +package evals + +import ( + "context" + "os" + "testing" +) + +// TestExampleSuiteParses keeps the documented example suite valid: it must +// always parse and build (with a judge agent available for its judge case). +func TestExampleSuiteParses(t *testing.T) { + data, err := os.ReadFile("../examples/evals/smoke.json") + if err != nil { + t.Fatalf("read example suite: %v", err) + } + suite, err := ParseSuite(data, BuildOptions{JudgeAgent: newAgent(&scriptedProvider{def: "{}"})}) + if err != nil { + t.Fatalf("example suite failed to build: %v", err) + } + if len(suite.Cases) == 0 { + t.Error("example suite has no cases") + } +} + +func TestParseSuiteBuildsScorers(t *testing.T) { + data := []byte(`{ + "name": "smoke", + "threshold": 0.6, + "cases": [ + { + "name": "greets", + "prompt": "greet the user", + "scorers": [ + {"type": "contains", "values": ["hello"]}, + {"type": "not_contains", "values": ["error"]}, + {"type": "regex", "pattern": "\\w+"}, + {"type": "equals", "value": "Hello there!"} + ] + } + ] + }`) + suite, err := ParseSuite(data, BuildOptions{}) + if err != nil { + t.Fatalf("parse: %v", err) + } + if suite.Name != "smoke" || suite.PassThreshold != 0.6 { + t.Errorf("suite meta = %q/%v", suite.Name, suite.PassThreshold) + } + if len(suite.Cases) != 1 || len(suite.Cases[0].Scorers) != 4 { + t.Fatalf("got %d cases / %d scorers", len(suite.Cases), len(suite.Cases[0].Scorers)) + } + + // Smoke-run the built suite against a scripted provider. + report, err := Runner{Agent: newAgent(&scriptedProvider{ + replies: map[string]string{"greet the user": "Hello there!"}, + })}.Run(context.Background(), suite) + if err != nil { + t.Fatalf("run: %v", err) + } + if !report.AllPassed() { + t.Errorf("built suite should pass: %+v", report.Cases[0]) + } +} + +func TestParseSuiteRejectsUnknownScorer(t *testing.T) { + _, err := ParseSuite([]byte(`{"name":"s","cases":[{"name":"c","prompt":"p","scorers":[{"type":"bogus"}]}]}`), BuildOptions{}) + if err == nil { + t.Error("expected error for unknown scorer type") + } +} + +func TestParseSuiteRejectsUnknownFields(t *testing.T) { + _, err := ParseSuite([]byte(`{"name":"s","cases":[],"bogus":true}`), BuildOptions{}) + if err == nil { + t.Error("expected error for unknown top-level field") + } +} + +func TestParseSuiteJudgeNeedsAgent(t *testing.T) { + data := []byte(`{"name":"s","cases":[{"name":"c","prompt":"p","scorers":[{"type":"judge","rubric":"be nice"}]}]}`) + if _, err := ParseSuite(data, BuildOptions{}); err == nil { + t.Error("judge scorer without a judge agent should fail to build") + } + if _, err := ParseSuite(data, BuildOptions{JudgeAgent: newAgent(&scriptedProvider{def: "{}"})}); err != nil { + t.Errorf("judge scorer with an agent should build: %v", err) + } +} + +func TestParseSuiteCommandNeedsCommand(t *testing.T) { + _, err := ParseSuite([]byte(`{"name":"s","cases":[{"name":"c","prompt":"p","scorers":[{"type":"command"}]}]}`), BuildOptions{}) + if err == nil { + t.Error("command scorer without a command should fail to build") + } +} diff --git a/examples/evals/smoke.json b/examples/evals/smoke.json new file mode 100644 index 0000000..106b7ad --- /dev/null +++ b/examples/evals/smoke.json @@ -0,0 +1,31 @@ +{ + "name": "smoke", + "threshold": 0.5, + "cases": [ + { + "name": "greets-the-user", + "prompt": "Greet the user warmly in one sentence.", + "scorers": [ + { "type": "regex", "pattern": "(?i)\\b(hi|hello|hey|welcome)\\b" }, + { "type": "not_contains", "values": ["as an AI language model"] } + ] + }, + { + "name": "answers-arithmetic", + "prompt": "What is 17 + 25? Reply with just the number.", + "scorers": [ + { "type": "contains", "values": ["42"] } + ] + }, + { + "name": "explains-recursion", + "prompt": "Explain recursion to a beginner in 2-3 sentences.", + "scorers": [ + { + "type": "judge", + "rubric": "A good answer explains that a function calls itself, mentions a base case (or stopping condition), is correct, and is understandable to a beginner. Penalize jargon-heavy or incorrect answers." + } + ] + } + ] +}