feat: upload benchmark results to Braintrust - #101
Draft
Rodriguespn wants to merge 18 commits into
Draft
Conversation
One Braintrust experiment per repo experiment, one trace per eval: root eval row (scores from passed+checks, scenario metadata, token metrics) with llm/tool/task/score child spans rebuilt from the persisted transcript. Dual-write only — eval-results.json is untouched (export refactored onto a shared results scan, output byte-identical). Runners now also persist whole-run usage (Claude Code result line, Codex turn.completed, ai-sdk totalUsage) plus startedAt/durationMs; extra raw-result keys only, dropped by the web export. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Guard transcript↔toolCalls pairing by tool name (ai-sdk completion order can differ from call order under concurrent tools) - Normalize Claude Code inputTokens to include cache reads/writes so token counts compare across agents (OpenAI-style accounting) - Tolerate corrupt raw JSON files: skip + warn instead of aborting - Load experiment metadata once; hoist repeated lookups Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This reverts commit 7953c07.
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
Trace-view counterpart of eval-results.json's headline passed flag, emitted ahead of the per-check score spans with a checksPassed summary. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Agent-agnostic turn model assembled from the canonical events: one turn per model API call with tool executions paired inside it, per-turn token usage and message ids (Claude Code stamps them from each assistant stream-json line), subagent sidechains nested under their Task call via parent_tool_use_id, codex grouped by closure rule, ai-sdk turns built straight from steps. Additive: the flat transcript/toolCalls scorer surface and eval-results.json are unchanged; raw files gain a trace field the Braintrust uploader prefers (llm turn spans owning their tool spans) with the flat mapping kept for older results. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
AI-907 Upload results to Braintrust and validate experiments
Upload the results to Braintrust and try the workflow with Braintrust Experiments. This follows the idea that using Braintrust's experiments from the beginning may be more efficient than maintaining a separate internal view. |
… spans A turn now means everything between user prompts (one per eval is normal); model API calls are steps inside it. Braintrust rows carry one scorer (passed) with the checks array in its output instead of a score per check. judge() calls are recorded via an AsyncLocalStorage collector (no per-eval wiring), persisted as judgeCalls in raw results, and rendered as llm children under the passed span with the rubric, the exact input the judge evaluated, model, duration, and tokens. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The runner persists stream-json/exec-json stdout as a sibling <evalId>.transcript.jsonl (never inside the result JSON — it can be megabytes; .jsonl keeps it out of the results scan and the web export). The uploader attaches it to the root span via Braintrust Attachments, the documented mechanism for large documents (object storage, no span size limits), next to the existing raw-result attachment. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Anthropic usage accounting extracted to agents/claude-code/usage.ts, shared by the runner (run totals) and parser (per-message) so the OpenAI-style convention can't drift; finiteNumber joins json.ts and replaces the codex runner's inline coercer - Stale <evalId>.transcript.jsonl siblings removed on re-runs that produce no raw transcript, so an old transcript can't attach to a new result - Dead per-event startMs capture deleted from the trace assembler (nothing consumed it; span timing is synthetic by design) - turnKey renamed stepKey to match the turn/step model; stale module and test wording corrected - rawTranscriptPathFor and makeFilterPredicate centralized in lib/result-files.ts / lib/cli-args.ts, deleting the duplicated sibling-path convention and suite filters - judge() no longer records an all-undefined usage object; internal recordJudgeCall and BraintrustSpanNode dropped from the public core exports Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…7f477ae Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Lets workflow_dispatch pin exact eval/experiment pairs and publish them to Braintrust regardless of each eval's own suite: field, without touching the tracked eval-results.json/regression exports. Blocked on main since it's meant for in-progress branch work only. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Rodriguespn
commented
Jul 22, 2026
| experiments_json="$(pnpm --silent eval -- list --experiment-suite "$experiment_suite")" | ||
| fi | ||
|
|
||
| if jq -e 'index("custom") != null' <<< "$suite_json" > /dev/null 2>&1; then |
Contributor
Author
There was a problem hiding this comment.
To run one (or more) arbitrary pair evals/experiments (ex: investigate-security-001-public-table/codex-gpt5.4-mini) without being tied to a pre defined batch (benchmark or regression). This workflow only run in branhces. It fails if we try to run on main
…ploads init() now passes baseExperiment (most recent prior experiment for the same model, discovered via the REST API, best-effort) so summarize() and the UI diff against the previous refresh. --summary-md writes a markdown table (experiment links, passed %, Δ vs base); the workflow appends it to the step summary and upserts a single marker-matched PR comment — the braintrustdata/eval-action reviewer experience without adopting its execution model. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ist links Experiments now carry trigger/branch/prNumber/ciRunId metadata and trigger/pr-N tags. The summary (and PR comment) links the experiments list pre-filtered by BTQL — all experiments from this CI run, this PR, or the scheduled main history — using the UI's search URL format. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Contributor
Author
Braintrust benchmark results
|
Main refreshes (--set-baseline, passed by CI only for full benchmark refreshes on main) stamp metadata.latestMain and demote the previous holder after a successful upload, so exactly one experiment per model is the source-of-truth baseline — stale main history never matches. PR uploads compare against it via baseExperiment; the PR comment table becomes Experiment | passed (n/m · %) | Δ vs main, each delta linking the pre-compared view (?c=), with exactly two footer links: this PR's results and the latest main results. Experiment metadata trimmed to what the filters need (branch, prNumber). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…load Per the agreed sequence for multi-experiment refreshes: fetch every current latestMain holder per experiment up front, upload and mark the whole new batch, and only then clear the saved old holders. A batch that dies mid-way leaves the previous baselines in place, and clearing every holder (not just the newest) self-heals duplicates left by a past failed demotion. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Store exactly the doc's experiment-metadata fields: the decomposed config identity (agent/modelProvider/modelId/reasoningEffort/skills), branch, prNumber, experimentSuite, trigger, runId — plus latestMain stamped post-upload. Drops source/gitShortSha/ciRunUrl. Storing skills as the same joined string identityKey queries on also fixes the latestMain lookup: since e978e43 the stored array shape could never match the joined-string filter, so PR runs would silently lose their baseline. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Uploading benchmark results to Braintrust.
This is an additive dual-write agreed in Slack:
eval-results.jsonstays exactly as it is (verified byte-identical) and remains the source of truth; Braintrust becomes a second destination for exploring and comparing results. Design doc: Braintrust as the storage layer of Supabase evals.How our results map to Braintrust
A Braintrust experiment is a group of agent runs against a set of scenarios. We create one experiment per model on every refresh, named
<experiment>@<git-sha>(e.g.claude-code-sonnet-5@fe0e167), so refreshes stay comparable over time — each new experiment is automatically diffed against the previous one for the same model.Inside an experiment, each eval is one row. The row's input is the scenario prompt (what Braintrust uses to match the same scenario across models), the output is the agent's final report, and the scenario dimensions (stage, product, topic, interface…) are attached as metadata and tags for filtering.
Example of a trace: claude-code-sonnet-5@59fc9c7-raw-transcript
Opening a row shows the full trace of what the agent did:
Task) nests under the tool call that spawned it.The shape is identical for Claude Code, Codex and ai-sdk: each agent's parser knows its own transcript format, and a shared agent-agnostic model (
AgentTracein core) assembles the tree.Scoring appears as a single
passedscorer — matching how the web UI headlines pass/fail — with the full checks list inside its output instead of one scorer per check, so the experiment table keeps one clean score column. When a check used an LLM judge, the judge call is nested under the scorer with the rubric, the exact input the judge read, and its model/duration/tokens: you audit what the judge saw instead of trusting its notes.Token metrics follow Braintrust's conventions (
prompt_tokens,prompt_cached_tokens, …), taken from each agent's own accounting; Claude input tokens are normalized to include cache reads/writes so counts compare across agents. The row covers the real run wall-clock; span ordering inside is synthetic (CLI transcripts carry no per-event timestamps) and flaggedstepTiming: synthetic-order-only.Every row also carries two attachments for forensics: the raw result JSON and the agent CLI's verbatim transcript (
<evalId>.transcript.jsonl).Experiment metadata
Each experiment carries exactly the metadata fields from the design doc's table — the decomposed config identity (
agent,modelProvider,modelId,reasoningEffort,skills) plus the run context (branch,prNumber,experimentSuite,trigger,runId, andlatestMainfor the current benchmark baseline) — so each is independently filterable/groupable in the Braintrust UI.skillsis stored as a stable sorted comma-joined string, the same shape the baseline lookup queries on.Baseline comparison
A full benchmark refresh on
mainstamps its experimentslatestMain(old holders are demoted only after the whole batch uploads, so a crashed refresh never loses the baseline). PR runs look up the baseline for the same config identity and set it as the Braintrust base experiment, so every PR experiment opens pre-diffed against the latest main results.In CI
After the export step, a
continue-on-errorupload runs for benchmark evals (missing credentials = a warning, never a failed job). The workflow upserts a single PR comment showing each experiment's pass rate, the Δ vs the latest-main baseline (linking to the pre-compared view), and pre-filtered links to this PR's experiments and the latest main results — see the comment on this PR for a live example produced by a simulated run. Ad hoc custom-suite dispatches upload under a branch-suffixed name so they're never mistaken for tracked results. Result files from before these fields existed still upload via a flat fallback.How to review
Core logic, in dependency order:
packages/core/src/transcript/agent-trace.ts+packages/core/src/agents/*— parsing CLI transcripts into the sharedAgentTracemodel and per-call usage capture.packages/core/src/braintrust-spans.ts— mapping a result + trace into the Braintrust span tree, row metadata, and token metrics (unit-tested).apps/framework/scripts/upload-braintrust.ts— the uploader: experiment naming/metadata, baseline lookup/rotation, summary markdown for the PR comment.apps/framework/lib/result-files.ts— result-file scan shared with the JSON export so the two destinations can't disagree..github/workflows/eval-refresh.yml— the upload + PR-comment steps (bothcontinue-on-error).Mechanical/generated (skim only):
apps/web/src/data/eval-results.jsonandregression-eval-results.jsonare regenerated eval artifacts (the big line count is churn from a results refresh, verified byte-identical export behavior);pnpm-lock.yamlis thebraintrustdependency.Closes AI-907