Repository navigation
v0.3.0
The last release before 1.0 that changes contracts. It adds:
--fail-on-regressionandsandtable comparefor regressions against a baseline--floorper-tag gates- a likely owner for each trial that didn't pass
- tool params that are objects and lists
- a library API cut down to what is documented,
results.jsonversions that readers check, and a stability policy for 1.0
It also makes leaderboard and demo --live treat critical tasks and errored trials as run does, and requires Node.js 22 and Python 3.10. Read the six Breaking: entries under Changed before upgrading.
Added
- Library:
parseDomain(value)checks a domain built in code asloadDomainchecks a file, the schema and then the cross-references, and throws aDomainError. A new page, sandtable.dev/reference/library, documents every export of the package (#370). results.jsonrecords the sandtable-ai that wrote it, assandtable: { version }, andsandtable regradeaddsregradedWith. Whenregraderefuses a run because what the agent or the caller is given changed, and the run came from another version, it says the version that ran it and the command that regrades it with that version, such asnpx sandtable-ai@0.2.1 regrade runs/results.json -d ./sandtable. A leaderboard's resumed results stay as stored unless a verdict changes (#372).sandtable run --fail-on-regression, with--baseline: the run exits 1 when a task that passed every graded trial in the baseline fails one now, and names each such task with its passes before and after, so a pull request that breaks a task fails CI even when pass^k stays above--fail-under. Tasks that got worse without ever passing every trial, tasks whose test changed since the baseline, and new failing tasks are still listed but don't gate. Without--baselineit is a usage error (exit 2), before any trial runs (#281).sandtable compare <baseline.json> <results.json>: shows what changed between two runs of one domain that already exist, with the same blockrun --baselineprints (broken, worse, fixed, better and changed tasks, added and removed ones, pass^1 and pass^k before and after), without calling a model. Use it for a pull request's run and the base branch's made in separate CI jobs, or for runs made without--baseline.--fail-on-regressionexits 1 when a task broke, and-o <dir>writes the newer run'sresults.jsonandreport.htmlwith the baseline attached. Runs of two domains exit 2 (#285).sandtable run --floor <tag>=<pct>, repeatable, gates one tag's pass^k as--fail-undergates the whole suite's, so a strong happy path can't carry weak refusals over the gate:--floor refusal=100 --floor negotiation=70exits 1 and names each tag below its floor, with or without--fail-under. A tag that no task in the run carries, a value that isn't<tag>=<pct>, a percentage outside 0 to 100 or a tag given twice stops the run before any trial with exit 2, so a typo can't make the gate pass. The By tag table in the terminal and inreport.htmlshows each floor and marks a tag below it, andresults.jsonrecords the floors asfloors(#283).sandtable leaderboard: when the board runs critical tasks, a Critical column in the terminal table,LEADERBOARD.mdandleaderboard.htmlshows how many each model passed in every trial,3/3, or✗ 2/3: expired-insurancewith the ones it failed. Each row ofleaderboard.jsonhas them ascritical: { tasks, failed }, and an error row from skipped trials hasskipped, how many there were (#284).- Failure attribution: each trial that didn't pass gets a first suspect in
results.json,attribution: { owner, evidence }, from deterministic rules, with no model call. The first rule that applies wins:harnessfor a skipped trial,task-specwhenlintproves the task can't pass as written,simulated-userwhen a caller note (a role break) comes before the trial's first hard failure, andagentotherwise, as the default.results.jsoncounts trials per owner inattribution, the terminal summary prints them on one line (not passed, by likely owner: agent 9 · simulated user 2), and the HTML report shows each trial's owner and evidence. pass^k, verdicts and exit codes don't change. Aresults.jsonfrom an earlier version has no attribution untilsandtable regradere-scores it. The library exports theAttribution,AttributionCountsandOwnertypes, and the plugin's attribution workflow starts from the field and says when its reading of the transcript disagrees (#29). - Domain spec: tool params can be objects and lists, so a tool can take line items or an address:
type: objectwithproperties, andtype: arraywithitems, nested to any depth. The agent gets nested JSON Schema, and an argument error names its path, such asargument "items[1].qty" must be an integer; an object or list a model sends as a JSON string is parsed.nullable: truekeeps an explicitnull, to clear a field. Templates read inside an object or list param ($address.zip,${items.0.sku}), a dotted field in any where clause reads a nested value ("address.zip": "94105"), and$containson a list matches an element, so a task can checkcalled: [{ tool: create_order, args: { items: { $contains: { sku: A-100, qty: 2 } } } }]. A model that leaves out a required nullable argument is told(it may be null). Existing domains load, run and grade as before, and their tool schemas andinputshashes don't change (#26). init --openapi: a request body's object fields and arrays become nested params, resolving$refs up to 3 levels deep, instead of being skipped. A body field it still can't map, such as one with several types or an array withoutitems, gets aTODO(review)note on its tool as well as the warning (#26).- Domain spec: a
requireinvariant can sayskip_missing: true, so a call naming a record that doesn't exist doesn't break that rule. A rule about a record's fields can then leave a made-up ID to a rule that the record exists (where: {}), and the made-up ID is reported once, under that rule (#163). - Docs: a stability policy, at sandtable.dev/reference/stability, says what 1.0 will keep stable (the domain spec,
results.jsonandleaderboard.json, CLI commands, flags and exit codes, the HTTP step protocol and the library's exports), what isn't covered, how version numbers signal a break, how long a deprecated item keeps working, and what counts as a change to a score (#352).
Changed
-
results.json:
report,regrade,run --baselineandleaderboardrefuse aresults.jsonwhose formatversionis newer than the one they read, with exit 2 and a message saying to use a newer sandtable-ai, instead of reading fields that may have changed meaning. The version moves only when a field is removed or changes meaning, never for an added field, asdocs/reference/results-schema.mdxnow states. Everyresults.jsonsandtable has written is version 1, and a hand-made one withoutversionis read as version 1, so none that loaded before is refused (#349). -
Breaking: Library: the package exports only its documented API, which 1.0 will keep stable. Removed:
- the zod schemas (
DomainSpec,TaskSpec,ToolSpec,ParamSpec,TableSpec,OpSpec,CheckSpec,FieldRef,InvariantSpec,StateAssertSpec,CallExpectSpec,ExpectSpec,ReferenceCallSpec,PersonaSpec,GivenSpec): useparseDomain(value)instead ofDomainSpec.parse(value). The TypeScript types (Domain,Task,CallExpect, …) are still exported, so zod's version is no longer part of sandtable's API. runEpisode,gradeTrial,regradeTrial,checkCall,invariantTool: userunSuitewithtaskIdsandk: 1, orregradeSuite.worldFor,seedFor,diffSnapshots: usenew World(domain.world)and itssnapshot().agentSystemPrompt: an agent receives the same prompt asAgentContext.system.userSystemPrompt,STOP_TOKEN,callerNotes,binom,DEFAULT_MODEL,DEFAULT_OPENAI_BASE_URL: internals with no replacement; a run'sresults.jsonstill carries each trial'scallerNotes.
Code that imports only the documented exports is unaffected (#370).
- the zod schemas (
-
Breaking: Node.js and Python: sandtable-ai needs Node.js 22 or later (
engines.nodeis>=22), and the PyPI launcher needs Python 3.10 or later. Node.js 20 reached end of life on 2026-04-30 and Python 3.9 in October 2025, and 1.0 shouldn't promise to keep supporting them. On Node 20,npm installandnpx sandtable-aiwarn about the engine, and an install withengine-strictfails;pip install sandtable-aion Python 3.9 keeps installing 0.2.1, the last launcher that supports it, which runsnpx sandtable-ai@0.2.1. Upgrade Node.js to 22 or later (a CI job'snode-version: 22), and Python to 3.10 or later for the launcher. CI now tests Node 22, 24 and 26 (#350). -
Breaking:
sandtable leaderboard: a model that failed any trial of a critical task now ranks below every model that failed none, whatever its pass^k, sincerunwould block its release; within each group, models rank by pass^k as before. A board with critical tasks can therefore order its rows differently from 0.2: a script that reads the top row ofleaderboard.jsonas the best model should check itscritical.failedtoo (#284). -
Breaking:
sandtable leaderboard: a trial that ended in an error now counts asruncounts it. One that ended in an error after the agent broke a hard rule or called a forbidden tool counts as failed, so the model is ranked with it instead of shown as an error row, and its results are stored and reused like any complete run. Only a skipped trial, one that ended in an error before the agent broke anything, still makes the model an error row, whose message now readsN of M trial(s) skipped after an error: <the first error>instead ofN of M trial(s) crashed: …(#284). -
Breaking:
sandtable demo --livenow appliesrun's gates: it exits 1 when any trial of one of the freight example's critical tasks failed, and 2 when a trial was skipped after an error. Before, it exited 0 whatever the agent did, unless every trial ended in an error. A script that runsdemo --liveand expects 0 should accept 1, as withrun:npx sandtable-ai demo --live || [ $? -eq 1 ](#284). -
Breaking:
examples/freight: four tasks' callers changed, so a live run fails them less often through no fault of the agent. Intwo-loads-rounds-restart,rounds-exhausted-then-alternativeandload-already-covered, the caller now knows where its truck is empty and when it can pick up. Before, a live caller asked about it made a date up, such as "I can roll tomorrow morning", and a correct agent then passed on a load that picks up later (#346). Inwalk-away-above-ceiling, the caller answers a low offer without repeating the agent's number. Before, a caller that misheard $1,350 as "fourteen fifty", the load's ceiling, led a correct agent to say $1,450 while correcting it, andnever-disclose-ceilingfailed (#383). Since a task's caller instructions are part of a run'sinputshash,sandtable regraderefuses a stored freight run that includes these tasks, andsandtable leaderboardstops before running anything when a model's stored freight results include them. Regrade such a run with the version that made it (npx sandtable-ai@0.2.0 regrade …); for a board, move those models' folders away or pass--no-resume, which reruns every model and costs model calls.run --baselineagainst an older freight run lists these four tasks aschanged. -
examples/freight: booking a made-up load ID now breaks the newbook-existing-loadinvariant instead ofrep-confirm-not-booked, so a leaderboard's rule-violation counts no longer list an invented load as a rep-confirm booking. The trial fails as before.sandtable regradere-files stored freight trials without a model call. Since the invariants are part of every task'sgradinghash,run --baselineagainst a freight run from an earlier version lists every task aschangedand leaves it out of the before-and-after scores, once; the exit code is unchanged (#163). -
Claude Code plugin: when reviewing drafted tasks,
drafting-a-domainfirst checks that each task keeps its chat's case (the customer's account and membership, the order, the item and the request, with only names and identifiers made fictional), and only then that the expected end state is what the SOP requires. Before, a drafted task that turned a gold member into a guest could be explained away as "the rep did something else" (#377). -
Roadmap:
ROADMAP.mdand sandtable.dev/roadmap list what 0.3.0 shipped, and show what is left for 1.0: a release candidate tested end to end, and leaving beta (#353).
Fixed
- Failure attribution and the Claude Code plugin: a trial left with the default
agentowner after a failed rule now quotes the rule's source in its evidence, or says the rule has none, so the rule gets checked before the agent is blamed.running-and-reading-resultsandfailure-triagertreat the stored attribution as a starting point, never as evidence: when their check finds another owner, such as the task spec for a rule stricter than the SOP, that owner is the answer instead of a caveat (#385). - Docs: the CLI reference lists
NO_COLOR, and the spec reference describessource: { file, quote }on invariants and tasks. Tests now fail when a reference page misses a command's option, an environment variable, a spec key or aresults.jsonfield, so the pages stay complete (#371).
What's Changed
Other changes
- test(docs): reference pages fail a test when they miss a flag, an env variable, a spec key or a results field by @Uliseis in #376
- feat(report): results.json readers refuse a format version newer than theirs by @Uliseis in #362
- feat(cli)!: the library exports only its documented api, adds parseDomain, and has a reference page by @Uliseis in #374
- build!: require node 22 and python 3.10, since node 20 and python 3.9 are end of life by @Uliseis in #368
- feat(report): results.json records the sandtable-ai that wrote it, and regrade names it when it refuses a run by @Uliseis in #380
- feat(cli): run --fail-on-regression fails ci when a task broke since the baseline by @Uliseis in #364
- feat(cli): sandtable compare shows what changed between two stored runs by @Uliseis in #367
- feat(cli): run --floor gates a tag's pass^k, such as refusal=100 by @Uliseis in #366
- feat(run)!: leaderboard and demo --live treat critical tasks and skipped trials as run does by @Uliseis in #369
- feat(grading): each trial that didn't pass names a likely owner, from deterministic evidence by @Uliseis in #373
- feat(spec): tool params can be objects and lists, and tasks grade them with $contains and dotted fields by @Uliseis in #378
- fix(examples)!: four freight callers know their truck's availability or don't repeat the agent's numbers by @Uliseis in #363
- fix(grading): a made-up load id is filed under its own freight rule, and require can skip missing records by @Uliseis in #365
- fix(plugin): reviewing a drafted task checks it keeps its chat's case before checking its end state by @Uliseis in #381
- docs(docs): a stability policy says what 1.0 keeps stable and how it changes by @Uliseis in #375
- fix(plugin): a stored attribution is a starting point, and a failed rule's evidence points at its source by @Uliseis in #386
- chore(release): v0.3.0 by @Uliseis in #387
Full Changelog: v0.2.1...v0.3.0