benchmark

package
v0.32.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 14, 2026 License: Apache-2.0 Imports: 24 Imported by: 0

Documentation

Overview

delegation.go — the benchmark's delegation axis: the run mode that selects how a run's work is delegated (coder only, worker delegation, or economy mode) and the per-run delegation metrics the report compares.

The mode is a measurement input like the model: "coder" (the default) changes nothing; "delegated" points the worker role at a cheaper model so subagents route to it; "economy" turns on economy mode, which routes subagents to the worker model and starts a fresh session's main loop on it. The metrics come from the agent's delegated-task outcome records, so a run reports what actually happened (how many delegated tasks were verified, escalated, or failed) rather than what the mode intended.

evidence.go — the runner's run-evidence capture: for a run the policy selects, the run's working-copy diff (against the baseline commit), the agent's transcript and the verification outcome are written under <EvidenceDir>/runs/<task>-<model>-<n>/, so a failed run can be inspected after the fresh copy is gone.

The capture runs inside runOnce, after the turn and before the copy is removed, so the diff reflects the turn's working-copy changes. It is best-effort: the policy decides which runs are kept, a missing evidence root means no evidence is kept at all, and any write failure is logged and swallowed — evidence is diagnostic material, never an input to the run's verdict.

models.go — the model list: the benchmark's default model list (the provider catalog's recommended_model entries) and the Runner's configurable override (Runner.Models, resolved by Runner.SuiteModels in runner.go).

The defining property: the default follows the live provider catalog, so when the catalog (embedded, or remote-refreshed) changes its recommendations the benchmark follows without code changes. The suite-level report iterates SuiteModels() × tasks; RunTask's 3-run rule per (model, task) is what each pair consumes.

report.go — the benchmark report: one structured source (Report) rendering as both JSON and Markdown, comparing the suite's models and starters, with the pass rate per starter per model computed over the 3 runs and the failure categories that guide skill and prompt fixes.

The report is a pure, deterministic function of its inputs — the run records, the suite's model list, and the run metadata (Meta): no clock, no network, and no map iteration in the renderers (the failure categories iterate in sorted key order). That is what makes the golden-file test possible (report_test.go pins the bytes) and the on-demand path safe: RunSuite (runner.go) feeds real runs in when a benchmark is run on demand — real models cost network and money, so the suite and the report are never part of `go test ./...`.

Publication (where the published results live) is the spec's open question and is deliberately not wired here.

runner.go — the benchmark runner: headless non-interactive runs of a benchmark task through the existing agent path, one fresh copy of the task's starter per run, 3 runs per model by default (so one lucky or unlucky run does not decide the result), with the run's pass/fail taken only from the turn's verification result (never from the model's own reply).

The turn-end verification hook is the verifier: the run's configuration enables verification, the hook runs the project's checks after the turn, and the runner reads the result the hook stored on the agent (Agent.LastVerificationResult). The runner never runs verify itself and never scores the reply text.

Per-run isolation: every run gets its own fresh directory (a fresh instantiation of the embedded starter + the task's frozen plan written via planstore), so a run can never observe another run's changes. The copy is removed when the run finishes, success or failure.

Record-and-continue: a benchmark reports every run. A run whose setup fails (unknown starter, plan write, copy shaping), whose agent build fails, or whose turn errors is recorded with Run.Err and the loop moves on; none of those failures is returned from RunTask.

runner_config.go — the runner's per-run configuration and the agent factory it builds from: the narrow config writes that force the run's measurement inputs (verification and change tracking on, the spec's model/provider in place), the default production agent factory, and the runs-per-model normalization. Split from runner.go to keep that file under the repo's per-file line budget.

runner_support.go — the runner's small private helpers, split from runner.go to keep that file under the repo's per-file line budget: the language-guard stat lookup, the work-dir resolution, and the fresh-copy MkdirTemp prefix.

runner_timeout.go — the runner's per-run timeout: the wall-clock limit one headless turn may take before the runner stops it. A benchmark run calls a real provider and a hung turn (a wedged HTTP call, a tool that never returns) would otherwise hold the whole suite hostage, so each run carries its own timer.

The stop uses the agent's own interrupt mechanism — TriggerInterrupt, the same cancel the CLI's Ctrl+C path fires — rather than inventing a second cancel path: the interrupt context is what the in-flight provider request derives from, so firing it makes the turn actually unwind. The unwind is waited on with a bounded grace so the runner itself can never hang: if the turn will not stop, the runner records the failure and moves on (the goroutine's channel is buffered, so it finishes without blocking).

runner_verify_gate.go — the runner's verification-gate guarantees: the run's non-interactive security posture, the git baseline it takes in each fresh copy, and the backstop that forces verification when the turn's change window stayed closed but the copy changed anyway.

The turn-end hook is driven by the agent's change tracker: it verifies only when the turn recorded application-code changes in its window (Agent.TurnChangedApplicationPaths). Two failure modes can leave that window empty on a turn that did change the copy:

  • A headless run has no approval surface. In an interactive session a shell command the classifier marks for approval is approved by the user; in a benchmark run there is nobody to ask, so the command is declined and the edit never lands — the model's work is silently lost. The runner therefore gives each run the non-interactive posture the run needs to actually work (applyRunSecurityPosture).
  • A change can reach the filesystem without reaching the tracker's window: a tool path not wired to the tracker, a shell command whose walk was skipped, or a subagent merge that missed the window. The git baseline and the backstop below catch that residual case: when the tracker reports no changes yet the copy has application-code changes on disk, the runner runs verification through the agent's forced seam so the run still produces a verdict.

Package benchmark owns the task suite of the agent benchmark: the on-disk task fixture format and the loader that reads and validates it.

A benchmark task is three things pinned together:

  • a plain-language request (what the agent receives);
  • a frozen plan (pkg/plancontract), whose acceptance criteria are the task's acceptance criteria;
  • a starter reference — the starter the task runs against.

Task files are static JSON fixtures committed to the repository under benchmarks/tasks/<starter-id>/<task-id>.json. The freeze is what makes benchmark runs comparable across models and over time: the request, the plan, and the starter never drift between runs. Pass/fail for a task comes only from the verification of the plan's acceptance criteria — never from the model's own report — and the runner (runner.go) enforces that contract.

The first implementation shipped the skeleton (the format, the loader, validation, and the committed fixture task); the runner adds headless non-interactive runs of a task through the existing agent path, one fresh starter copy per run, 3 runs per model by default. Per-task metrics and the default model list from the provider catalog have landed; reports are still to come.

The package's dependencies (pkg/plancontract, and for the runner pkg/agent, pkg/verify, pkg/starters, pkg/planstore, pkg/factory, pkg/configuration) are all supported on the js/wasm target, so the whole package builds for js/wasm. The runner's tests drive real shell commands and real (scripted) agent turns and are !js-only, like the verification fixture tests.

Index

Constants

View Source
const (
	// RunModeCoder runs every step on the coder model — the baseline.
	RunModeCoder = "coder"
	// RunModeDelegated points the worker role at the spec's worker model so
	// subagents route to it (worker delegation without economy mode).
	RunModeDelegated = "delegated"
	// RunModeEconomy turns on economy mode: subagents route to the worker
	// model and a fresh session's main loop starts on it.
	RunModeEconomy = "economy"
)

Run modes a benchmark run can use. The empty string means the default ("coder"), so an existing run configuration is unchanged.

Variables

View Source
var ErrInvalidTask = errors.New("benchmark: invalid task")

ErrInvalidTask is returned when a task file parses but is not a valid task: a task-field violation (id, request, starter), a plan that fails plancontract.Validate, or — under LoadSuite — a starter field that does not match the task's directory. Callers detect it with errors.Is(err, ErrInvalidTask). File-level failures (unreadable or missing file, malformed JSON) are NOT wrapped in ErrInvalidTask: they wrap the underlying os or encoding/json error, so a caller can tell "the file does not parse" from "the file is not a valid task".

View Source
var ErrRunTimeout = errors.New("benchmark: run timed out")

ErrRunTimeout is wrapped into a run's Run.Err when the run's turn was stopped for exceeding the runner's per-run timeout. Callers detect it with errors.Is (mirroring the package's ErrInvalidTask contract).

Functions

This section is empty.

Types

type AgentFactory

type AgentFactory func(runDir string, spec ModelSpec) (*agent.Agent, error)

AgentFactory builds the headless agent for one run. It receives the run's fresh copy directory (starter instantiated, frozen plan written, ShapeCopy applied) and must return an agent whose workspace root is that directory. The default factory (a real provider client from pkg/factory plus agent.NewAgentWithClient, the production SDK path) is used when Runner.AgentFactory is nil; tests and custom harnesses supply their own (e.g. a scripted model).

type DelegationMetrics added in v0.32.1

type DelegationMetrics struct {
	// TaskCount is the number of delegated tasks the run recorded.
	TaskCount int `json:"task_count"`
	// Verified / Escalated / Failed are the per-outcome task counts.
	Verified  int `json:"verified"`
	Escalated int `json:"escalated"`
	Failed    int `json:"failed"`
	// WorkerTokens is the tokens the worker model completed (verified
	// without escalation), and TotalTokens the tokens across all delegated
	// tasks.
	WorkerTokens int `json:"worker_tokens"`
	TotalTokens  int `json:"total_tokens"`
	// EscalationRate is Escalated/TaskCount (0 when there is no delegated
	// work): the share of ALL delegated tasks the cheap model could not
	// finish and the coder model had to take over. The denominator is every
	// delegated task (not only worker-role ones), so the rate reads as the
	// run's overall escalation share. This is the headline number for
	// whether a cheaper model "holds up".
	EscalationRate float64 `json:"escalation_rate"`
	// WorkerTokenShare is WorkerTokens/TotalTokens (0 when no delegated
	// work): the share of delegated work the worker model handled.
	WorkerTokenShare float64 `json:"worker_token_share"`
}

DelegationMetrics is a run's delegation outcome: how the run's delegated tasks finished, and the run's share of work done on the worker model. Zero for a coder-only run (no delegation happened).

type DelegationReport added in v0.32.1

type DelegationReport struct {
	// RunMode is the mode these runs used (RunModeCoder when unset).
	RunMode string `json:"run_mode"`
	// Runs is the number of runs in this mode.
	Runs int `json:"runs"`
	// Passed is how many of those runs passed.
	Passed int `json:"passed"`
	// PassRate is Passed/Runs (0 when there are no runs).
	PassRate float64 `json:"pass_rate"`
	// DelegatedTasks is the pooled delegated-task count.
	DelegatedTasks int `json:"delegated_tasks"`
	// Escalated is the pooled escalated-task count, and EscalationRate the
	// pooled share (0 when no delegated tasks).
	Escalated      int     `json:"escalated"`
	EscalationRate float64 `json:"escalation_rate"`
	// Tokens and Cost are the pooled totals across the mode's runs.
	Tokens int     `json:"tokens"`
	Cost   float64 `json:"cost"`
}

DelegationReport pools one run mode's runs so the report can compare "coder only" with "coder + worker delegation" (and economy mode) on the same tasks: the pooled pass rate, the escalation rate (how often the cheap model could not finish and the coder model took over), and the token/cost totals.

type KeepRuns added in v0.24.1

type KeepRuns string

KeepRuns is the run-evidence policy: which runs leave their working-copy diff, agent transcript and verification output under the runner's EvidenceDir. The zero value ("") means KeepRunsFailed — the default.

const (
	// KeepRunsFailed keeps evidence for every run that is not Passed (the
	// default): a failed run is exactly when the diff, transcript and
	// verification output are needed.
	KeepRunsFailed KeepRuns = "failed"
	// KeepRunsAll keeps evidence for every run, passed or not.
	KeepRunsAll KeepRuns = "all"
	// KeepRunsNone never keeps evidence.
	KeepRunsNone KeepRuns = "none"
)

func ParseKeepRuns added in v0.24.1

func ParseKeepRuns(value string) (KeepRuns, error)

ParseKeepRuns parses a --keep-runs value: "failed", "all" or "none" (case-insensitive, surrounding space trimmed). Any other value is an error naming the accepted ones, so a bad CLI value is a clear usage error rather than a silently ignored flag.

type Meta

type Meta struct {
	RunDate string
	Version string
}

Meta is the report's run metadata: the run date the results are published under and the sprout version the runs used (production passes buildinfo.Version; tests pass fixed values, so the report is a pure function of its inputs).

type ModelSpec

type ModelSpec struct {
	// Model is the model id to request. Empty → the run's configuration
	// default for the provider.
	Model string
	// Provider is the provider name to run against (e.g. "anthropic").
	// Empty → the run's configuration default provider.
	Provider string
	// RunMode selects how the run delegates work: RunModeCoder (or empty —
	// the default), RunModeDelegated, or RunModeEconomy. See delegation.go.
	RunMode string
	// WorkerModel / WorkerProvider point the worker role at a cheaper model
	// for RunModeDelegated / RunModeEconomy. An empty WorkerModel leaves the
	// worker role unset, in which case delegation is a no-op and the run
	// behaves like RunModeCoder. An empty WorkerProvider uses the run's own
	// provider (the run's active provider, pinned by configureRun).
	WorkerModel    string
	WorkerProvider string
}

ModelSpec selects the model (and optionally the provider) one set of benchmark runs uses. The suite-level model list (Runner.Models) is a []ModelSpec, resolved by Runner.SuiteModels — the default list (the provider catalog's recommended_model entries, models.go) when unset.

func DefaultModelList

func DefaultModelList() []ModelSpec

DefaultModelList is the production entry point for the benchmark's default model list: the live provider catalog's recommended_model entries. The list follows the catalog — embedded or remote-refreshed — so a change of recommendations flows into the benchmark without code changes; that is the defining property.

func DefaultModels

func DefaultModels(catalog providercatalog.Catalog) []ModelSpec

DefaultModels derives the benchmark's default model list from catalog: one ModelSpec per provider whose RecommendedModel is non-empty, in catalog provider order, pairing the provider's id with its recommended_model. Providers without a recommended_model are skipped. A catalog with no recommended entries (no providers, or none carrying one) yields nil.

type Report

type Report struct {
	Meta Meta `json:"meta"`
	// Models is the suite's model list (SuiteModels order) — recorded
	// as passed in, never re-derived from the runs.
	Models []ModelSpec `json:"models"`
	// Tasks are the (task, model, provider) pair reports in first-seen
	// order (the suite's model × task order when the runs come from
	// RunSuite).
	Tasks []TaskReport `json:"tasks"`
	// Starters pool the (task × run) matrix per (starter, model,
	// provider) in first-seen order — the "pass rate per starter
	// per model".
	Starters []StarterReport `json:"starters"`
	// FailureCategories counts the failed runs per failure mode (see
	// failureCategories); nil — omitted from the JSON — when nothing
	// failed.
	FailureCategories map[string]int `json:"failure_categories,omitempty"`
	// Delegation compares the suite's runs by delegation mode (coder only,
	// worker delegation, economy): task success, escalation rate, tokens
	// and cost. Empty when no run recorded a mode.
	Delegation []DelegationReport `json:"delegation,omitempty"`
}

Report is the benchmark report: one structured source rendering as both JSON and Markdown — comparing models and starters, with the pass rate per starter per model over the 3 runs and the failure categories that guide skill and prompt fixes.

func BuildReport

func BuildReport(runs []Run, models []ModelSpec, meta Meta) Report

BuildReport assembles the report from the suite's runs. It is pure and deterministic: the same inputs always yield byte-identical JSON and Markdown (no clock, no network, no reordering beyond the sorted failure-category keys). Tasks group the runs by (TaskID, Model, Provider) in first-seen order — the suite's model × task order when the runs come from RunSuite — and each pair's Runs keep run order. Starters pool the (task × run) matrix per (Starter, Model, Provider) in first-seen order: the pass rate per starter per model (pooled — the number a product's readiness bar reads). models is the suite's model list (SuiteModels order) — it is recorded as-is, never re-derived from the runs.

func (Report) JSON

func (rep Report) JSON() ([]byte, error)

JSON renders the report as deterministic, indented JSON: Go marshals the struct in field order, sorts the failure-categories map keys, and renders the runs' time.Time fields as RFC3339 — the bytes are a function of the report's contents alone (the golden fixture pins the exact timestamps). A run-level error (Run.Err) marshals as {} — standard library error types carry the message in unexported fields; the message itself is on Run.Err for programmatic use and in the Markdown's result cell ("error: <one-line err>"). A trailing newline follows.

func (Report) Markdown

func (rep Report) Markdown() string

Markdown renders the report as deterministic Markdown: no clock and no map iteration — the models line and the task detail follow the report's slice order, and the failure categories iterate in sorted key order. The starter table carries the pooled pass rate per starter per model, and each task table carries the cost and turns columns the acceptance criteria pin.

type Run

type Run struct {
	// TaskID is the benchmark task this run executed (Task.ID).
	TaskID string
	// Starter is the task's starter id (Task.Starter).
	Starter string
	// Model is the requested model (ModelSpec.Model, "" when the run's
	// configuration default applies).
	Model string
	// Provider is the requested provider (ModelSpec.Provider, "" when
	// the run's configuration default applies).
	Provider string
	// RunNumber is the 1-based run number within this task+model.
	RunNumber int
	// Passed is the run's acceptance outcome, derived only from Result.
	Passed bool
	// Result is the turn's verification result, stored by the
	// turn-end hook, or nil when the hook never ran (verification
	// disabled, the turn changed no code, a setup error before the turn,
	// or a verify runner setup error).
	Result *verify.Result
	// NotVerifiedReason states WHY a failed run has no verification
	// result (set only when Result is nil): "no code changes this turn"
	// (the hook's change gate stayed closed), "verification setup error"
	// (the hook entered but its runner failed to set up), "verification
	// did not run this turn" (the hook still did not run), or "verify
	// timed out" (the run's turn exceeded Runner.Timeout). Empty when
	// Result is non-nil, and also when verification was disabled — which
	// a benchmark run forces on, so an empty reason on a nil result
	// means there is nothing honest to record beyond the category.
	NotVerifiedReason string
	// Err is a run-level failure (fresh-copy setup, plan write, agent
	// build, or the agent turn itself). Non-nil means the run did not
	// complete cleanly; such a run is never Passed.
	Err error
	// StartedAt is when the run started (fresh-copy creation).
	StartedAt time.Time
	// FinishedAt is when the run finished (the copy is removed).
	FinishedAt time.Time
	// Turns is the number of top-level agent turns the runner issued for
	// this run (1 for the current single-request runner; the field grows
	// naturally when multi-request tasks land).
	Turns int
	// RepairRounds is the number of turn-end verification repair rounds
	// the hook ran within the turn (0 when verification never ran).
	RepairRounds int
	// RepairAttempts is the per-check repair attempts the hook consumed
	// against the stopping rule, keyed like the hook's per-check counters
	// (empty when verification never ran).
	RepairAttempts map[string]int
	// RepairLimit is the stopping-rule limit that was in effect for the
	// turn (0 when verification never ran).
	RepairLimit int
	// Tokens is the run's total token usage: the agent's conversation
	// total — the run's agent is fresh, so the conversation total is the
	// run's usage (existing cost tracking).
	Tokens int
	// Cost is the run's total cost (the agent's conversation total cost).
	Cost float64
	// LangChecks is how many final messages the language guard
	// judged for the run's model during the run (a delta of the
	// process-wide per-model metric).
	LangChecks int64
	// LangMismatches is how many of those judged messages were a
	// reliable language mismatch (a delta).
	LangMismatches int64
	// RoleUsage is the run's per-role token/cost breakdown: the run's
	// agent's per-role usage, captured from its metrics.
	// Empty when the run errored before the agent's turn recorded usage.
	RoleUsage []agent.RoleUsage
	// RunMode is the delegation mode the run used (RunModeCoder when unset).
	RunMode string
	// Delegation is the run's delegation outcome (task/verified/escalated/
	// failed counts and the worker share). Zero for a coder-only run.
	Delegation DelegationMetrics
}

Run is one benchmark run: one headless agent turn for one task with one model in one fresh copy of the task's starter.

Passed is derived ONLY from the turn's verification result (Result) — never from the model's own reply: a run whose reply claims success but whose verification failed, or never produced a passing result, records Passed = false.

Strictness pin: verify.Result.Passed() is false for an all-skipped run (a run that verified nothing is neither a pass nor a failure), so Passed = Result != nil && Result.Passed() is exactly "a passing verification result". An all-skipped run, a run whose turn changed no code (the hook's change gate never opened), and a run whose turn or setup ended in an error all record as fail: no passing result, no pass.

The per-task metrics are the fields below the wall-time anchors: the repair data the turn-end hook stored on the agent, the runner's turn count, the run's token and cost totals (the agent's existing conversation cost tracking — the run's agent is fresh, so the conversation total is the run's usage), the run's per-role token/cost breakdown (the agent's per-role usage), and the run's share of the process-wide language-guard metric. A setup-error run (Err set before the agent was built) leaves every metric zero; a run whose agent was built but whose turn errored still records its metrics (the turn was issued and may have consumed usage).

type Runner

type Runner struct {
	// ConfigManager owns the per-run configuration. Before each agent
	// build the runner writes the run's requirements into it with
	// UpdateConfigNoSave — verification forced on (the benchmark's
	// pass/fail source must run; the manager's other verification
	// settings are preserved) and the spec's model/provider where set —
	// and a benchmark run never persists a run's model choice into the
	// user's configuration. The writes are narrow, never clearing: an
	// empty spec field leaves the manager's current value, and writes
	// persist across RunTask calls on the same runner — a caller that
	// wants isolated defaults per model supplies a fresh manager.
	// nil is allowed with a custom AgentFactory that manages its own
	// configuration.
	ConfigManager *configuration.Manager
	// AgentFactory builds the headless agent for one run (the default
	// factory, above, is used when nil).
	AgentFactory AgentFactory
	// ShapeCopy is an optional per-copy hook (tests / custom harnesses)
	// run after instantiation + plan write and before agent creation —
	// e.g. to override the copy's .sprout/starter.json commands. nil by
	// default: production runs use the starter's own manifest.
	ShapeCopy func(runDir string, task *Task) error
	// RunsPerTask is the runs-per-model count. 0 (or
	// negative) → defaultRunsPerTask (3).
	RunsPerTask int
	// WorkDir is the parent directory for the fresh copies. "" →
	// os.TempDir(). It is created (with parents) if missing.
	WorkDir string
	// Models is the suite's model list: the models a
	// suite run benchmarks against. Empty → the default list (the
	// provider catalog's recommended_model entries, models.go); set it
	// to override the default. SuiteModels is the single resolution
	// point.
	Models []ModelSpec
	// Timeout is the per-run wall-clock limit: a run whose turn exceeds
	// it is stopped through the agent's real interrupt (TriggerInterrupt,
	// the CLI's Ctrl+C mechanism) and recorded as failed, wrapping
	// ErrRunTimeout in Run.Err — never Passed. 0 (or negative) →
	// defaultTaskTimeout (10m). Each run carries its own timer.
	Timeout time.Duration
	// KeepRuns is the evidence policy: which runs leave their failed
	// working-copy diff, agent transcript and verification output under
	// EvidenceDir. "" resolves to KeepRunsFailed — the default, so a run
	// never silently discards the evidence a failure needs.
	KeepRuns KeepRuns
	// EvidenceDir is the root directory the per-run evidence lands under
	// (EvidenceDir/runs/<task>-<model>-<n>/). "" → no evidence is kept,
	// regardless of KeepRuns: a runner with no evidence root has nowhere
	// to write the evidence. Evidence writing is best-effort — a
	// failure to write never changes a run's verdict.
	EvidenceDir string
}

Runner runs a benchmark task's headless runs.

A zero-value Runner is usable with a custom AgentFactory (tests and custom harnesses); the default factory additionally needs ConfigManager to target the run's model/provider and to enable verification for the run.

func (*Runner) RunSuite

func (r *Runner) RunSuite(ctx context.Context, tasks []*Task) ([]Run, error)

RunSuite runs the suite: each model in the runner's model list (SuiteModels — the override, or the default list) against every task, RunTask per (model, task) pair (RunsPerTask runs each — the 3-run rule), returning the aggregated runs in suite order: models in list order, tasks in slice order, run number within the pair. It is the on-demand path for the report — real models cost network and money, so tests exercise it only with the Scripted client.

Return contract: (runs, nil) when every pair completed; (runs, err) when RunTask's suite-level error fires mid-suite — the runs accumulated so far are returned alongside (a partial report is still buildable), matching RunTask's own partial-result contract. A per-run failure never fires the error path: it rides on Run.Err exactly as in RunTask (record-and-continue).

func (*Runner) RunTask

func (r *Runner) RunTask(ctx context.Context, task *Task, spec ModelSpec) ([]Run, error)

RunTask runs task's request with one model (spec) RunsPerTask times — 3 by default, so one lucky or unlucky run does not decide the result — and returns one Run record per run, in run order.

Each run is headless (the existing non-interactive agent path: one ProcessQuery turn with the task's request) and isolated: a fresh copy of the task's starter, one per run, with the task's frozen plan written into it. The run's pass/fail comes only from that turn's verification result — the turn-end hook runs the checks against the copy, and the model's own reply is never read for scoring.

Return contract:

  • (runs, nil): every scheduled run was attempted. A run's own failure (setup, agent build, provider error) is carried on Run.Err — a benchmark reports every run, and one bad run never aborts the task.
  • (runs, err): ctx was cancelled between runs (the completed runs are returned alongside the wrapped context error), the task is invalid (nil task or empty request), the runner is nil, or WorkDir cannot be created.

Cancellation is honored between runs, the per-run timeout mid-turn: a run whose turn exceeds Runner.Timeout is stopped through the agent's real interrupt (TriggerInterrupt, the CLI's Ctrl+C mechanism) and recorded as a failed run wrapping ErrRunTimeout — the loop moves on. A ctx cancellation that lands mid-turn does not stop the in-flight turn (it cannot reach a turn that is already running); the loop stops before the next run.

func (*Runner) SuiteModels

func (r *Runner) SuiteModels() []ModelSpec

SuiteModels returns the suite's model list: Models when non-empty, otherwise the default list (the provider catalog's recommended_model entries, models.go). nil receiver → the default list. This is the single resolution point for the model list and the seam the report iterates: models × tasks, each (model, task) pair through RunTask's 3-run rule.

type StarterReport

type StarterReport struct {
	Starter  string
	Model    string
	Provider string
	// Tasks is how many distinct tasks of this starter the report
	// covers.
	Tasks int
	// RunsTotal is the pooled task × run matrix size.
	RunsTotal int
	Passed    int
	// PassRate is the pooled Passed/RunsTotal (0 when RunsTotal is 0).
	PassRate float64
}

StarterReport pools one (starter, model) pair's task × run matrix: the "pass rate per starter per model" over the 3 runs — the pooled rate a product's readiness bar would read.

type Task

type Task struct {
	// ID is the task identifier: non-empty, a single safe path
	// segment. Convention: the file name without .json.
	ID string `json:"id"`
	// Request is the plain-language request the agent receives.
	Request string `json:"request"`
	// Starter is the starter ID the task runs against.
	Starter string `json:"starter"`
	// Plan is the frozen plan: the machine-readable
	// plancontract.Plan. It must validate (plancontract.Validate);
	// its acceptance criteria are the task's acceptance criteria.
	Plan plancontract.Plan `json:"plan"`
}

Task is one benchmark task: a plain-language request, a frozen plan, and a starter reference. Task files are static fixtures committed to the repo — that is what "frozen" means: runs are comparable across models and over time because the request, the plan, and the starter never drift.

func LoadSuite

func LoadSuite(dir string) ([]*Task, error)

LoadSuite loads every task fixture under dir from the layout <dir>/<starter-id>/<task-id>.json, and returns the tasks in deterministic order — sorted by starter directory name, then by file name — so a suite loads identically on every machine and every run.

Layout rules:

  • A starter directory is any subdirectory of dir; files directly under dir are ignored.
  • A task file is a .json file directly under a starter directory. Non-.json files and subdirectories inside a starter directory are ignored: a task is exactly one level deep.
  • The task's starter field MUST equal its starter directory name (the mirror of pkg/starters' embedded-tree/descriptor check): the suite can never point at another starter's tasks. The mismatch is an error naming the file.
  • The file name conventionally equals the task id plus .json, but a mismatch is NOT an error: the format stays lenient where the spec is silent, and the loader never guesses an id from the file name.

A missing dir is an error (a loader precondition, detectable with errors.Is(err, fs.ErrNotExist)), not a not-found sentinel. A dir that exists but holds no task files returns an empty (non-nil) slice and a nil error: an empty suite is a valid result, and a caller that needs tasks can check len.

func LoadTask

func LoadTask(path string) (*Task, error)

LoadTask reads one task file, unmarshals it, and validates it against the task rules and plancontract.Validate. On any error the returned task is nil, so a caller never observes an invalid task (the pkg/planstore and pkg/starterstore loader contract).

Error contract:

  • Unreadable file (missing file, permission): a wrapped os error; a missing file is detectable with errors.Is(err, fs.ErrNotExist).
  • Malformed JSON (including an empty file or a non-object document): a wrapped encoding/json error.
  • Parsed but invalid (a task-field violation, or a plan that fails plancontract.Validate): ErrInvalidTask wrapping the problems; the message names the file and lists every problem.

type TaskReport

type TaskReport struct {
	TaskID string
	// Starter is the task's starter id.
	Starter  string
	Model    string
	Provider string
	// Runs are the pair's runs in run number order.
	Runs []Run
	// Passed is how many of the runs passed.
	Passed int
	// PassRate is Passed/len(Runs) (0 when there are no runs).
	PassRate float64
}

TaskReport is one (task, model) pair's slice of the report: the pair's runs in run order (the full per-run metrics), the pair's pass count, and the pair's pass rate (Passed over len(Runs); 0 when the pair has no runs).

type ValidationError

type ValidationError struct {
	Problems []string
}

ValidationError describes the structural problems found in one task. It carries one entry per problem so callers can surface them all at once, mirroring plancontract.ValidationError for plans.

func (*ValidationError) Error

func (e *ValidationError) Error() string

Error implements error. It always lists every problem so the message is actionable on its own.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL