// cli · config

Configuration

every evalshift.yaml field

Every EvalShift run is driven by a single evalshift.yaml file. This page documents every field — types, defaults, and what they do.

evalshift init writes a heavily-commented starter you can edit to your needs. Below is the canonical reference.

Top-level shape

yaml
version: 1                # optional; defaults to 1, must be 1
project: org/slug         # optional; Cloud push needs it or --project
prompts: [...]            # required, at least one
defaults: {...}           # optional
evaluators: {...}         # optional (but at least one is needed for evaluate)
suites: {...}             # optional; managed block, rewritten by capture sync
captures: {...}           # optional; the object store the SDK ships captures to
migration_policy: {...}   # optional; regression budgets and the verdict
retention: {...}          # optional; how much run history to keep
−
strict by default
Unknown keys are rejected (extra: forbid everywhere) so typos fail fast instead of silently dropping.

project (Cloud)

Optional. Identifies which EvalShift Cloud project a run belongs to when you push. Format is org-slug/project-slug (regex ^[a-z0-9-]+/[a-z0-9-]+$). On first push, EvalShift auto-creates the project if your token has owner or admin access to the org and you didn’t pass --no-create-project.

Leave it unset for local-only use; push needs it or --project. See Getting started for the full Cloud walkthrough.

thresholds was removed

evalshift.yaml used to accept a free-form thresholds: block next to project:. It was never read by anything that gates: the migration verdict comes from migration_policy, and the Cloud gate reads the resolved policy the bundle carries. Nothing replaced it. Since CLI 1.1.0 a config that still sets the key fails to load, and push no longer syncs it. Delete the block; if you were using it to express a gate, encode that as a migration_policy budget instead — migration_policy is the only gating block.

top-level slices was removed

evalshift.yaml also used to accept a top-level slices: list (name, filter, applies_to). It was validated and recorded in the run bundle, but analysis never read it: slices come from example tags (see Slices), so none of its fields renamed, filtered or scoped anything. Since CLI 1.2.0 a config that still sets the key fails to load, with an error saying so. Delete the block — a run reports the same slices without it — and put per-slice budgets under migration_policy.slices, keyed by tag.

Cloud baselines are unaffected for any config that never set the key (or set slices: []): the run’s config hash does not move. Deleting a non-empty block does change that hash, so runs pushed afterwards are not comparable to earlier baselines until the base branch pushes a run with the edited config.

prompts

A list of prompt definitions. Each entry has:

FieldTypeRequiredDescription
idstringyesStable identifier surfaced in reports. Must be unique within the file.
detectionenumyesmanual or python_string.
contentstringwhen detection: manualInline prompt body. Forbidden when detection: python_string.
pathstringwhen detection: python_stringRelative or absolute path to a .py file. Resolved against the directory containing evalshift.yaml.
variablestringwhen detection: python_stringModule-level variable name holding the prompt string.
variableslistoptionalNames of {template} placeholders the prompt expects. Used by the pre-flight compatibility check.
max_tokensintoptionalPer-prompt override of defaults.max_tokens.

Two prompt-detection modes

manual — write the prompt body inline:

yaml
- id: greet
  detection: manual
  content: "Hello {name}"
  variables: [name]

python_string — point at an existing module-level string in your codebase:

yaml
- id: greet
  detection: python_string
  path: src/prompts/greet.py
  variable: GREET_PROMPT
  variables: [name]

EvalShift AST-walks the file and extracts the string literal. It does not run user code. F-strings, concatenations, .format() calls, and other dynamic forms are explicitly rejected.

+
nothing on the prompt makes it an agent prompt
The toolset comes from each golden-suite example (toolset_ref or inline tools), not from the prompt — so one prompt can dispatch some examples plainly and others with tools in the same run. See Agent migrations.

defaults

FieldTypeDefaultDescription
source_modelstring(none)Default --from model id (or alias).
target_modelstring(none)Default --to model id (or alias).
judge_modelstringgemini-3.1-flash-lite-previewDefault LLM-as-judge model. Note that evaluators.llm_judge[*].judge_model has its own default and does not read this field.
insights_modelstring(falls back to judge_model)Model that writes the run narrative rendered at the top of report.html.
concurrencyint10 (1 ≤ x ≤ 64)Max in-flight LLM calls — applies to run and evaluate.
max_tokensint4096Completion cap on every call. A call cut off at the cap is marked truncated and excluded from the statistics — a truncated output is a broken measurement, not a model verdict.
cachebooltrueRead/write the local SQLite cache at ~/.evalshift/cache.db.
max_cost_usdfloat50.0Soft ceiling reserved for future enforcement. The pre-flight cost prompt currently triggers above $10 (skip with --yes).
samples_per_exampleint1 (1 ≤ x ≤ 20)How many times each (prompt, example) is sent to each model. Above 1, every sample is its own live call (raw.jsonl rows carry sample_index, and the cache keys on it), sample i of the source is scored against sample i of the target, and the example’s scores.jsonl row becomes the mean source_score, target_score and delta over its samples, with the per-sample lists and the population delta_variance under metadata.samples. The paired tests still run over examples, so n is unchanged. Cost and the pre-flight call count multiply by it. See Methodology.

evaluators

Eight sub-keys, all optional: structural, semantic, llm_judge, tool_selection, tool_arguments, tool_trace_structure, agent_trace, and trace_invariants. At least one evaluator must be configured for evalshift evaluate to do anything.

+
blocking: true by default
Every evaluator config — in all eight families — also takes blocking: bool (default true). Advisory evaluators (blocking: false, what a fresh evalshift init writes) still score and still appear in reports, but never move the migration verdict. Since CLI 1.4.0 the flag also decides what a missing API key does: compare, run and evaluate check every semantic and llm_judge model before the first call, skip an advisory one without a key (recorded as skipped_evaluators) and exit 1 on a blocking one — including one that just omits blocking:. Export the key or set blocking: false; see Blocking vs advisory.

evaluators.structural

A list. Each entry has a type and the fields that type needs.

typeRequired fieldsBehaviour
json_schemaschema_pathOutput is parsed as JSON; score 1.0 if it validates against the schema, 0.0 otherwise.
regexpatternScore 1.0 if the regex matches anywhere in the output, 0.0 otherwise.
lengthmin_chars and/or max_charsScore 1.0 inside the bounds, distance-decayed outside.

Optional applies_to: ["prompt-id-glob", ...] (default ["*"]) for future per-prompt scoping.

evaluators.semantic

A single object (not a list).

FieldTypeDefaultDescription
embedding_modelstringtext-embedding-3-smallLiteLLM-compatible embedding model id. The bare default is OpenAI’s and needs OPENAI_API_KEY; without one use a Gemini id (e.g. gemini/gemini-embedding-001, what the Gemini scaffold writes). The Anthropic and DeepSeek scaffolds write OpenAI’s or Gemini’s, whichever key is already exported.
min_similarityfloat0.9Cosine similarity below which the target is flagged SEMANTIC_REGRESSION. Raise to 1.0 to flag any deviation from byte-identical.

When both outputs are empty — a turn that ended in tool calls — the evaluator writes no record and makes no provider call: nothing was measured, so nothing is scored. One empty side still scores — 0.0 similarity by definition, with no embedding call, gated by min_similarity as usual — because a silent target is a real regression.

The semantic evaluator scores the target’s similarity to the source: target_score = cosine(source, target), source_score = 1.0. A negative delta means the target drifted from the source’s meaning.

evaluators.tool_selection

FieldTypeDefaultDescription
namestring(required)Identifier surfaced in reports.
conformanceenumexpectedGrades each side absolutely against the example’s ground truth (one record, kind: tool_selection.conformance). expected matches example.expected_tools in order; expected_set is the same, order-insensitive multiset recall — for parallel fan-outs; off disables the axis. A row both sides missed is tagged TOOL_GROUND_TRUTH_MISS and excluded from the policy rates.
divergenceenumsetGrades the target against the source (one record, kind: tool_selection.divergence; the source is its own baseline at 1.0). set is Jaccard on tool names — reordered identical calls don’t read as drift; exact is sequence equality; first compares the first call only; off disables the axis. Both axes off is a config error.
applies_tolist["*"]Glob list of prompt ids.
severity_floorenumnulllow | medium | high | critical — a regression on either axis is never classified below the floor.

The two axes answer different questions — “did each model do what production did?” and “did the candidate change behaviour?” — and render as separate, separately-labelled rows in report.html. (The old single mode: key was removed; a stale one fails validation.)

evaluators.tool_arguments

FieldTypeDefaultDescription
namestring(required)Identifier.
applies_tolist["*"]Glob list.
strategiesdict{}Per-field strategy overrides (exact/subset/numeric/semantic/auto). Keys are bare field names, matched across every tool. semantic borrows the embedding model from evaluators.semantic; without one — or when semantic is skipped for a missing API key — it degrades to exact. A blocking entry that uses the semantic strategy makes that embedder’s key mandatory: without it the run exits 1, naming this entry.
default_strategyenumautoStrategy for every field strategies does not name. auto is a ladder, cheapest rung first: normalized exact (case and whitespace), then the field’s schema type from the example’s toolset (ids, enums, booleans, dates/uuids/emails exact; numbers numeric; objects and arrays subset), then graded similarity for free text — semantic when an evaluators.semantic block lends an embedding model, a difflib ratio when not. exact restores byte-equality scoring.
numeric_tolerancefloat0.05Relative-error tolerance for numeric.
againstenumsourcesource scores drift from the current model, which pins its own score at 1.0 by construction — it measures change, not correctness. expected scores both models against expected_tools[].arguments; each expectation’s match_strategy picks the compared keys, an expected call the model never made scores 0, and an example with no recorded arguments is skipped.
optional_fields_scoredenumlenientHow a field present on one side only is scored: lenient gives it 0.5 (omitting an optional parameter is a difference, not a wrong value), strict gives it 0.0.
use_llm_judge_fallbackboolfalseReserved; currently ignored.

evaluators.tool_trace_structure

FieldTypeDefaultDescription
namestring(required)Identifier.
applies_tolist["*"]Glob list.
check_call_countbooltrueScore the number of tool calls.
check_parallelismbooltrueScore parallel-vs-sequential alignment.
check_refusalsbooltrueScore refusal alignment; mismatches force severity_floor: high.
call_count_toleranceint1+/- N calls considered equivalent.

evaluators.agent_trace

A list. These evaluators score timelines imported with evalshift traces import; they do not run your agent and do not replace the normal raw.jsonl model-call artifact.

FieldTypeDefaultDescription
namestring(required)Identifier surfaced in scores and reports.
applies_tolist["*"]Glob list of prompt ids.
check_tool_orderbooltrueCompare source and target tool-call order.
check_argumentsbooltrueCompare arguments for same-name matched tool calls.
check_missing_verificationbooltrueCheck that dangerous tools have an earlier verification tool.
verification_toolslist[]Tool names treated as verification steps.
dangerous_toolslist[]Tool names that should not appear without verification or as extras.

evaluators.trace_invariants

A list of rule sets: hand-written, deterministic rules over the tool calls in each trace, checked on both sides of every pair. Put them in the top-level evaluators: block, outside the region capture sync manages, or in a suite’s own evaluators override. Why and when to write one is on Evaluators. Since CLI 1.3.0 the CLI reads the block; an older CLI fails to load a config that sets it.

yaml
evaluators:
  trace_invariants:
    - name: payments_contract
      owner: "@payments-team"     # optional; echoed onto every record and the report
      applies_to: ["checkout-*"]  # prompt-id globs; default ["*"]
      blocking: true              # default; false = reported, never gates
      traces: replayed            # default; `imported` checks `evalshift traces import` traces
      rules:
        - id: auth-before-charge
          type: order
          before: authenticate
          after: charge_card
        - id: no-legacy-refund
          type: forbidden
          tools: [refund_v1]
        - id: one-charge
          type: call_count
          tool: charge_card
          max_calls: 1              # min_calls, max_calls or both; both equal = exact
        - id: must-book
          type: required
          tools: [create_booking]
        - id: sane-amount
          type: arguments
          tool: charge_card
          json_schema:
            type: object
            required: [amount]
            properties:
              amount: {type: number, minimum: 0.01, maximum: 5000}

migration_policy:
  max_invariant_violations: 0     # default; examples where the target broke any blocking rule
FieldTypeDefaultDescription
namestring(required)Identifier surfaced in scores and reports.
applies_tolist["*"]Glob list of prompt ids. Enforced: a rule set checks only examples whose prompt id matches; evaluate warns when the globs match no prompt in the run.
blockingbooltruefalse reports the rule set’s violations but never gates.
tracesenumreplayedreplayed checks the run’s own replayed responses; imported checks traces attached with evalshift traces import. imported on a run without traces.jsonl fails evaluate; a replayed pair with no tool trace gets no row.
ownerstring(none)Echoed onto every record and the report.
ruleslist(required)The rules — at least one, each id unique within the entry. Each has an id, a type, and the fields that type needs (below); order takes two different tools, and call_count needs at least one bound with max_calls ≥ min_calls.

The unit of checking is a response: one model turn, together with the tool calls already in its context before it answered. With traces: replayed there is one response per replayed round, and round k’s context is the tool calls on the example’s history assistant turns plus the recorded calls of rounds 0..k-1 that the replay fed back. The model’s own earlier rounds are not context, because a teacher-forced replay never feeds them back. With traces: imported the whole trace is one response with an empty context: every call in it was the agent’s own.

Context calls can never be violations — the model under test did not make them — but they can satisfy a rule or count toward it:

typeFieldsViolated whenContext role
forbiddentoolsThe model calls any tool in tools (one violation per call).None.
requiredtoolsSome tool in tools is called in no response by the model.None: a call only in the context does not satisfy it. Use call_count min_calls when the conversation may already contain the call.
orderbefore, afterThe model calls after and no before precedes it, either in the context or earlier in the same response.A context before satisfies it.
call_counttool; min_calls, max_calls or bothmax_calls: a model call to tool takes the running count past it, counting context calls first (one violation per excess call). min_calls: at the end of the last response, context calls plus the model’s calls to tool number fewer than it (one violation). Both set and equal is exact cardinality.Context calls count, toward both bounds.
argumentstool, json_schemaA model call to tool has arguments that fail json_schema (Draft 2020-12).None.

“Earlier in the same response” means emitted earlier. Most harnesses execute one response’s calls in order, and imported traces are ordered by sequence_index, so the two modes agree.

required and min_calls are not vacuous. order, arguments and a call_count with only max_calls say nothing about a trace that never calls the tool. required and call_count with min_calls fail on every in-scope trace that lacks the calls. On a single-shot replay they can only see the first response, so scope them with applies_to, or use them with a suite promoted by capture sync --rounds all or imported traces.

required asks about the model, call_count about the conversation. required asks whether the model under test made the call; call_count asks whether the conversation contains it. A recorded history that already called charge_card satisfies min_calls: 1 and leaves max_calls: 1 no room.

Teacher-forced rounds see recorded context. min_calls is judged on the last response’s context plus that response’s own calls. The model’s own earlier rounds were never fed back, so they do not count.

Malformed arguments fail a schema that requires a field. Arguments that did not parse are checked as recorded ({"_parse_error": true}), with no special case, so the field the schema requires is missing.

Each side’s score is the share of rules it broke zero times, (rules − rules_broken) / rules, and delta = target − source. The record’s kind is trace_invariants; its metadata carries rules_checked, source_violations and target_violations (each {rule_id, rule_type, tool, round_index, detail}), owner when set, and failure_categories: ["INVARIANT_VIOLATION"] when the target broke anything. A row where both sides broke a rule still counts against the target, and adds a recommendation line: either both models break the rule, or the rule is stale and needs review with its owner. The HTML report’s Trace rules broken panel lists every rule the target broke whatever the delta, with its owner, whether the source broke it too, and advisory rows tagged “advisory”; report.json carries the same rows top-level as invariant_violations (advisory included, flagged by blocking), and Top regression causes counts INVARIANT_VIOLATION as distinct examples with a blocking target violation — the same number the budget sees. Blocking rule sets gate through max_invariant_violations.

evaluators.llm_judge

A list of pairwise judges. Each entry has:

FieldTypeRequiredDescription
criterion_namestringyesShort id surfaced in reports.
criterion_promptstringyesFree-form criterion the judge applies (e.g. "which output preserves more factual detail?").
judge_modelstringoptionalModel used as the judge (default gemini-3.1-flash-lite-preview, which needs GEMINI_API_KEY or GOOGLE_API_KEY). Prefer a judge from a third model family so it isn’t grading its own relatives.

Judge family. LLM judges tend to prefer output from their own relatives (self-preference bias), and nothing in the scoring can remove that. When a judge_model resolves to the same provider as defaults.source_model or defaults.target_model, evalshift doctor prints a warn-level judge family row and evalshift validate a matching line — never a failure, because init deliberately scaffolds a same-provider judge so a first run needs one API key. The report repeats the note above the verdict whenever a judge that actually contributed llm_judge rows shares a family with an arm, and report.json carries it as judge_family_overlap. “Family” is the provider the model id resolves to (anthropic, deepseek, google, openai); ids the registry cannot place never match. defaults.judge_model is exempt: it only seeds the run-narrative model, never a pairwise verdict.

The judge sees both outputs (with random A/B order to defang positional bias) and produces strict-JSON {"winner": "A"|"B"|"tie", "reason": "..."}. Target wins → (0.0, 1.0); tie → (0.5, 0.5); source wins → (1.0, 0.0). Malformed responses degrade to (0.5, 0.5) with the error preserved.

Slices

Slices come from example tags automatically; there is nothing to configure. Every distinct tag in the suite becomes a slice under its own name, plus the implicit "all" slice, and each is analysed separately. Per-slice budgets go under migration_policy.slices, keyed by the tag. There is no top-level slices: key — it was removed.

overall is reserved and cannot be used as an example tag or as a migration_policy.slices key. It names the run-level scope in the run bundle, so a slice by that name would shadow the whole-run numbers wherever the two are rendered together. Both spellings are rejected when the config or suite loads.

Tag-derived slices with identical membership are collapsed to one before analysis, so a duplicate never enters the Benjamini–Hochberg family twice; all and any slice named under migration_policy.slices always survive.

suites

A managed block mapping a suite name to the file behind it, so evalshift run --suite-name <name> can select one without a path. evalshift capture sync rewrites everything between its markers except a suite’s hand-written trace_invariants family — edit around them, not inside.

yaml
# >>> evalshift suites (managed by `evalshift capture sync`) >>>
suites:
  support_agent:
    source: captured
    path: .evalshift/suites/support_agent/golden.jsonl
# <<< evalshift suites <<<
FieldTypeRequiredDescription
sourcestringoptionalcaptured (default; built by capture promote) or jsonl (hand-authored). Advisory provenance; both resolve to path.
pathstringyesPath to the suite JSONL, relative to the config file's directory.
evaluatorsblockoptionalEvaluators this suite is scored with, replacing the top-level evaluators: family by family: a family the suite doesn’t mention is inherited, a family it mentions replaces the top-level one wholesale, and [] or null removes it. Omitted, the suite is scored with the top-level block unchanged.
managedbooloptionalWhether capture sync owns this entry (default true). Set false to freeze hand edits; sync then prints what it would have written instead. A hand-written trace_invariants family survives a managed regeneration without it.

capture sync regenerates a managed entry’s path and evaluators from what the suite’s captures contain. A suite whose rows were offered a toolset gets a tool_selection evaluator named routing (conformance: expected, divergence: set), plus a tool_arguments evaluator named routing_args (against: expected) when rows recorded arguments; a tool-free suite gets no block and inherits the top level; a tool evaluator it inherits (including trace_invariants) skips every pair that was offered no tools, and evaluate warns that it checked nothing. The per-suite block is only used when the suite is selected by name (--suite-name); a raw --suite <path> resolves to the top-level block.

Regenerating a managed entry keeps a hand-written trace_invariants family in its evaluators block.

captures

Optional. Where the capture SDK’s recordings live when they are not on this machine. Omit the block for the default: captures are read from .evalshift/captures/ (or EVALSHIFT_DIR) on local disk and nothing here is needed.

Production agents on Fargate, Lambda or Kubernetes lose their disk when they stop, so the SDK can ship captures to an object store you own instead (EVALSHIFT_CAPTURE_STORE=<uri> in the agent’s environment; the older name EVALSHIFT_SINK still works — see Config, sinks & hygiene). Name the same store here and the CLI pulls new captures down before it promotes them, with no flags to remember:

yaml
captures:
  store: s3://acme-evals/support-agent   # or gs://<bucket>/<prefix>, az://<account>/<container>/<prefix>
FieldTypeDefaultDescription
storestring (URI)(none)Object store the SDK writes to. capture sync and capture list fetch new captures and toolset sidecars from it into the local directory first; capture fetch does only that step.
−
bump the CLI before adding the key
CLI versions before 1.3.0 reject the captures: key (the config is strict), so bump the CLI — and a pinned GitHub Action’s evalshift-version — before adding it.

Accepted URI forms — the same grammar the SDK uses:

FormStoreInstallCredentials
s3://<bucket>/<prefix>Amazon S3; MinIO, Cloudflare R2, Backblaze B2 via boto3’s AWS_ENDPOINT_URLpip install boto3IAM role in CI (OIDC), aws sso login locally
gs://<bucket>/<prefix>Google Cloud Storagepip install google-cloud-storageWorkload Identity in CI, gcloud auth application-default login locally
az://<account>/<container>/<prefix>Azure Blob Storagepip install azure-storage-blob azure-identityManaged Identity / federated credential in CI, az login locally

<prefix> is optional. Credentials never go in the URI: a value containing @ or ? fails to load, naming the accepted forms, and a rejected value is never echoed back (it may be a pasted key or connection string) — the error names at most its scheme. Under the prefix the layout is exactly the local one — captures/<suite>/cap_<hex>.json and toolsets/<hex>.json — so the bucket is a mirror of .evalshift/, not a different format.

What the fetch does: it lists the bucket, downloads only objects missing locally (captures are immutable and toolsets content-addressed, so “exists” means “current”), skips captures already promoted into a suite (so capture clean never causes a re-download), and writes each file atomically. --since 24h (or 7d, 30m, an ISO date) limits a fetch to recently written captures; --offline skips it and works with what is already local. Retention in the bucket is yours to set with a lifecycle rule; the SDK’s max_captures does not apply there. Reads use your own cloud credentials; nothing from the bucket goes to EvalShift Cloud. See capture fetch.

migration_policy

Optional. Turns the statistics into a decision: analyze writes migration_decision.json with a verdict, and --policy-gate exits non-zero when that verdict is fail or conditional_pass (or no policy is configured). Rate budgets are fractions, not percents; max_critical_regressions and max_invariant_violations are counts.

FieldDefaultMeaning
max_overall_regression_rate0.30Share of blocking records allowed to regress.
max_critical_regressions1Count of critical-severity regressions. An integer, not a ratio.
min_equivalence_rate0.75Floor on the non-regression rate — equivalent or improved both count.
max_tool_argument_drift0.20Share of tool-argument rows allowed to drift.
max_tool_divergence0.20Share of tool_selection.divergence rows where the target called different tools than the source.
max_invariant_violations0Count of distinct (prompt_id, example_id) examples with a blocking trace_invariants row whose target score is below 1.0 — an example that broke rules in two entries counts once, and an example averaged over repeated samples counts when any one sample broke a rule. A breach is always conclusive, however small the sample, and overrides inconclusive and conditional_pass to fail. Only checked in a scope with at least one blocking trace_invariants row; advisory-only rule sets add no budget row. Since CLI 1.3.0 the CLI checks it; an older CLI fails to load a config that sets it.
tool_argument_drift_floor0.9Target argument score below which a call counts as drifted. Argument scores are continuous — without a floor every non-identical call counts and the drift budget is unreachable.
max_cost_increase0.30Relative increase in mean per-call cost, target vs source.
max_latency_increase0.30Relative increase in mean per-call latency.
fail_on_dropped_paramsfalseFail the verdict when state.json → dropped_params is non-empty — an arm could not honour a generation parameter the source capture recorded (the report shows a Constraints not honoured banner either way). Top level only: a model either accepts a parameter or does not, which no slice can vary. Runs recorded before this field existed are never failed by it.
slices{}Per-slice overrides keyed by example tag; unset fields inherit the top level. A slice budget blocks on the same terms as an overall one — breach it conclusively and the run fails.

evalshift init --profile writes pre-tuned budgets for a migration shape: model-upgrade (the default), cost-reduction, local-model, quantization, provider-switch. Since CLI 1.3.0 every profile block includes max_invariant_violations: 0, which older CLIs reject — bump the GitHub Action’s evalshift-version pin to 1.3.0 or later before committing it. The rate budgets carry a 95% Wilson interval, so a breach only fails when the sample can confirm it — a thin suite returns inconclusive instead of a confident fail, while a budget the observation held stays conclusive however wide its interval. See Migration policy.

retention

Optional. Run history under .evalshift/runs/ is pruned per suite after every completed run: max_runs_per_suite (default 20; 0 disables) keeps the newest N, and optional run_ttl_days evicts anything older. In-progress runs and the run just finished are never pruned. Override the count with EVALSHIFT_MAX_RUNS, or prune on demand with evalshift runs clean.

Suite (golden.jsonl) shape

The suite is JSON Lines — one example per non-blank line. Every example must also carry a toolset — exactly one of toolset_ref or inline tools. The core fields are below; the toolset, the agent ground truth (expected_tools, expected_tool_rounds, …), generation_config, and the multi-turn fields are documented in full on Golden suite.

FieldTypeRequiredDescription
idstringyesUnique within the suite.
inputsobjectoptionalMapping of template-variable name to value (default {}); must cover the prompt’s variables.
tagslistoptionalSlice tags.
expectedobjectoptionalReference output (unused by most evaluators).

Unknown keys are rejected (typos fail fast).

Want to see how all of this plays together for an agent migration? Read the agents page.