diff --git a/.changeset/eval-artifact-timing.md b/.changeset/eval-artifact-timing.md new file mode 100644 index 0000000000..376d48ea51 --- /dev/null +++ b/.changeset/eval-artifact-timing.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +`eve eval` artifacts now record each eval's `startedAt` and `completedAt` in `summary.json`, `results.jsonl`, and the per-eval detail file. diff --git a/.github/scripts/discover-e2e-fixtures.mjs b/.github/scripts/discover-e2e-fixtures.mjs index 31c98af3cd..ff718cb8cc 100644 --- a/.github/scripts/discover-e2e-fixtures.mjs +++ b/.github/scripts/discover-e2e-fixtures.mjs @@ -146,7 +146,7 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) { else process.stdout.write(result.lines); } -function discoverEvalIds(root) { +export function discoverEvalIds(root) { const evalIds = []; const visit = (directory) => { for (const entry of readdirSync(directory, { withFileTypes: true }).sort((a, b) => diff --git a/.github/scripts/stage-benchmark-leg.sh b/.github/scripts/stage-benchmark-leg.sh new file mode 100755 index 0000000000..6a23321f88 --- /dev/null +++ b/.github/scripts/stage-benchmark-leg.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Stages one eval-benchmark leg for upload as $RUNNER_TEMP/leg: +# meta.json leg identity and runner context +# expected.json `eve eval --list --json` output, when the run got that far +# run/ the leg's single `.eve/evals/` tree, when one exists +# The publish job (scripts/eval-benchmark/records.mjs) reads this layout. +set -euo pipefail + +leg="$RUNNER_TEMP/leg" +rm -rf "$leg" +mkdir -p "$leg" + +jq -n \ + --arg leg_id "$LEG_ID" \ + --arg sha "$PLANNED_SHA" \ + --arg model_id "$MODEL_ID" \ + --arg world "$WORLD" \ + --argjson attempt "$ATTEMPT" \ + --arg runner_os "$RUNNER_OS_NAME" \ + '{leg_id: $leg_id, sha: $sha, model_id: $model_id, world: $world, attempt: $attempt, runner_os: $runner_os}' \ + >"$leg/meta.json" + +if [ -s "$RUNNER_TEMP/expected.json" ]; then + cp "$RUNNER_TEMP/expected.json" "$leg/expected.json" +fi + +shopt -s nullglob +runs=("$FIXTURE_DIR"/.eve/evals/*/) +if [ "${#runs[@]}" -gt 1 ]; then + echo "::error::Expected one .eve/evals tree in $FIXTURE_DIR, found ${#runs[@]}." + exit 1 +fi +if [ "${#runs[@]}" -eq 1 ]; then + cp -R "${runs[0]}" "$leg/run" +fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 29dd0000ce..63f032cc0e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -98,6 +98,9 @@ jobs: - name: Test workflow helpers run: node --test .github/scripts/*.test.mjs + - name: Test eval benchmark scripts + run: pnpm test:eval-benchmark + test-integration: name: test-integration (${{ matrix.os }}) runs-on: ${{ matrix.os }} diff --git a/.github/workflows/eval-benchmark.yml b/.github/workflows/eval-benchmark.yml new file mode 100644 index 0000000000..f00335cfba --- /dev/null +++ b/.github/workflows/eval-benchmark.yml @@ -0,0 +1,376 @@ +name: Eval Benchmark + +# Tracks correctness, latency, and cost of fixture evals across models and eve +# versions; see research/model-benchmark-tracking.md. Runs only on trusted +# `main` revisions and never on pull requests. Run legs hold model credentials +# only; the publish job is the only one that holds the Blob token. +on: + schedule: + # Nightly; skipped when main has not moved since the last successful run. + - cron: "23 7 * * *" + # TEMPORARY: lets a same-repo PR exercise the workflow end to end and publish + # under preview/runs/. The `run-eval-benchmark` label runs one model, one + # fixture, and one attempt; `run-eval-benchmark-full` runs the nightly matrix + # with one attempt instead of the registry's three. Remove this trigger and every `pull_request` branch below + # before merge: it hands the Blob token to PR code. + pull_request: + types: [labeled] + # Also dispatched by release.yml after an eve release publishes. + workflow_dispatch: + inputs: + models: + description: Comma-separated e2e/benchmark.json model names (empty for all, "none" for mock legs only) + type: string + default: "" + fixtures: + description: Comma-separated e2e/benchmark.json fixture names (empty for all) + type: string + default: "" + attempts: + description: Attempts per fixture and model (empty for the registry default) + type: string + default: "" + +concurrency: + group: eval-benchmark-main + cancel-in-progress: false + +permissions: + contents: read + +jobs: + plan: + name: plan + runs-on: ubuntu-latest + # TEMPORARY: pull_request gate; see the trigger above. + if: >- + github.event_name != 'pull_request' || + ((github.event.label.name == 'run-eval-benchmark' || + github.event.label.name == 'run-eval-benchmark-full') && + github.event.pull_request.head.repo.full_name == github.repository) + permissions: + contents: read + actions: read + outputs: + run: ${{ steps.skip.outputs.run }} + sha: ${{ steps.skip.outputs.sha }} + live_matrix: ${{ steps.plan.outputs.live_matrix }} + mock_matrix: ${{ steps.plan.outputs.mock_matrix }} + judge: ${{ steps.plan.outputs.judge }} + max_parallel: ${{ steps.plan.outputs.max_parallel }} + evals_per_leg: ${{ steps.plan.outputs.evals_per_leg }} + steps: + - name: Require main + if: github.event_name != 'pull_request' && github.ref != 'refs/heads/main' + run: | + echo "::error::Benchmark results publish only from refs/heads/main (got ${GITHUB_REF})." + exit 1 + + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # TEMPORARY: a PR plans its head commit, not the merge commit. + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: .nvmrc + + - name: Skip an unchanged nightly run + id: skip + env: + GH_TOKEN: ${{ github.token }} + EVENT_NAME: ${{ github.event_name }} + PLANNED_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + echo "sha=${PLANNED_SHA}" >> "$GITHUB_OUTPUT" + if [ "$EVENT_NAME" = "schedule" ]; then + last_sha="$(gh run list --repo "$GITHUB_REPOSITORY" --workflow eval-benchmark.yml \ + --branch main --status success --limit 1 --json headSha --jq '.[0].headSha // ""')" + if [ "$last_sha" = "$GITHUB_SHA" ]; then + echo "main is still at the last benchmarked commit ${GITHUB_SHA}; skipping." + echo "run=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + fi + echo "run=true" >> "$GITHUB_OUTPUT" + + - name: Plan legs + id: plan + if: steps.skip.outputs.run == 'true' + env: + # TEMPORARY: pull request runs use one attempt. `run-eval-benchmark` + # also narrows to one model and fixture; `run-eval-benchmark-full` + # keeps every registry model and fixture. + BENCHMARK_MODELS: ${{ github.event.label.name == 'run-eval-benchmark' && 'openai-high' || inputs.models }} + BENCHMARK_FIXTURES: ${{ github.event.label.name == 'run-eval-benchmark' && 'agent-tools' || inputs.fixtures }} + BENCHMARK_ATTEMPTS: ${{ github.event_name == 'pull_request' && '1' || inputs.attempts }} + PLANNED_SHA: ${{ steps.skip.outputs.sha }} + run: node scripts/eval-benchmark/plan.mjs "$RUNNER_TEMP/plan.json" "$PLANNED_SHA" + + - name: Upload plan + if: steps.skip.outputs.run == 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: benchmark-plan + path: ${{ runner.temp }}/plan.json + if-no-files-found: error + retention-days: 1 + + # Live-model legs: fixture × model × attempt on the local world. Eval + # failures are data, so neither the eval run nor the job fails the workflow. + # e2e/benchmark.json `concurrency` caps legs (gateway load) and evals per + # leg (runner contention, which skews latency). + run-live: + name: run-live (${{ matrix.fixture }}, ${{ matrix.model_id }}, ${{ matrix.attempt }}) + needs: plan + if: needs.plan.outputs.run == 'true' && needs.plan.outputs.live_matrix != '[]' + runs-on: ubuntu-latest + continue-on-error: true + timeout-minutes: 90 + strategy: + fail-fast: false + max-parallel: ${{ fromJSON(needs.plan.outputs.max_parallel) }} + matrix: + include: ${{ fromJSON(needs.plan.outputs.live_matrix) }} + env: + AI_GATEWAY_API_KEY: ${{ secrets.AI_GATEWAY_API_KEY }} + EVE_E2E_MODEL: ${{ matrix.model_id }} + EVE_E2E_JUDGE_MODEL: ${{ needs.plan.outputs.judge }} + EVALS_PER_LEG: ${{ needs.plan.outputs.evals_per_leg }} + EVE_SANDBOX_IMAGE_TAG: latest + steps: + - name: Checkout planned commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.plan.outputs.sha }} + persist-credentials: false + + - name: Setup pnpm + uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: .nvmrc + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build eve package + run: pnpm --filter eve run build + + - name: Build extension fixtures + # Mirrors e2e-local: extension fixtures ship no committed dist/. + run: | + pnpm --filter "./e2e/fixtures/*-extension" run --if-present build + pnpm --filter dist-extensions update gizmo-extension gadget-extension + + - name: Run fixture evals + working-directory: ${{ matrix.dir }} + run: | + pnpm run --if-present e2e:prepare + pnpm exec eve eval --list --json > "$RUNNER_TEMP/expected.json" + if ! pnpm exec eve eval --verbose --max-concurrency "$EVALS_PER_LEG"; then + echo "::warning::Some evals failed; failures are recorded as benchmark data." + fi + + - name: Stage leg artifact + if: always() + env: + LEG_ID: ${{ matrix.leg_id }} + FIXTURE_DIR: ${{ matrix.dir }} + PLANNED_SHA: ${{ needs.plan.outputs.sha }} + MODEL_ID: ${{ matrix.model_id }} + WORLD: ${{ matrix.world }} + ATTEMPT: ${{ matrix.attempt }} + RUNNER_OS_NAME: ${{ runner.os }} + run: bash .github/scripts/stage-benchmark-leg.sh + + - name: Upload leg artifact + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: benchmark-leg-${{ matrix.leg_id }} + path: ${{ runner.temp }}/leg + if-no-files-found: error + retention-days: 1 + + # Mock-model legs: one per fixture and workflow world. With no model latency + # or cost, their timings are close to pure eve overhead. + run-mock: + name: run-mock (${{ matrix.fixture }}, ${{ matrix.world }}) + needs: plan + if: needs.plan.outputs.run == 'true' && needs.plan.outputs.mock_matrix != '[]' + runs-on: ubuntu-latest + continue-on-error: true + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(needs.plan.outputs.mock_matrix) }} + # Unused by local-world legs; a service container cannot vary per leg. + services: + postgres: + image: postgres:18-alpine + env: + POSTGRES_DB: world + POSTGRES_PASSWORD: world + POSTGRES_USER: world + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready --username=world --dbname=world" + --health-interval 5s + --health-timeout 5s + --health-retries 12 + env: + # Mock models never reach the gateway. + AI_GATEWAY_API_KEY: invalid-e2e-world-suite-key + EVE_E2E_MODEL: mock + EVALS_PER_LEG: ${{ needs.plan.outputs.evals_per_leg }} + EVE_SANDBOX_IMAGE_TAG: latest + WORKFLOW_POSTGRES_MAX_POOL_SIZE: "52" + WORKFLOW_POSTGRES_URL: postgres://world:world@127.0.0.1:5432/world + WORKFLOW_POSTGRES_WORKER_CONCURRENCY: "50" + steps: + - name: Checkout planned commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.plan.outputs.sha }} + persist-credentials: false + + - name: Setup pnpm + uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: .nvmrc + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build eve package + run: pnpm --filter eve run build + + - name: Build extension fixtures + run: | + pnpm --filter "./e2e/fixtures/*-extension" run --if-present build + pnpm --filter dist-extensions update gizmo-extension gadget-extension + + - name: Run fixture evals on the local world + if: matrix.world == 'local' + working-directory: ${{ matrix.dir }} + run: | + pnpm run --if-present e2e:prepare + pnpm exec eve eval --list --json --exclude-tag real-model > "$RUNNER_TEMP/expected.json" + if ! pnpm exec eve eval --verbose --exclude-tag real-model --max-concurrency "$EVALS_PER_LEG"; then + echo "::warning::Some evals failed; failures are recorded as benchmark data." + fi + + - name: Run fixture evals on a production server + if: matrix.world != 'local' + working-directory: ${{ matrix.dir }} + run: | + set -euo pipefail + pnpm run --if-present e2e:prepare + pnpm exec eve eval --list --json --exclude-tag real-model > "$RUNNER_TEMP/expected.json" + if [ "$WORLD" = "postgres" ]; then pnpm exec bootstrap; fi + pnpm exec eve build + server_log="$RUNNER_TEMP/server.log" + EVE_DEV=1 pnpm exec eve start --host 127.0.0.1 --port 3000 >"$server_log" 2>&1 & + server_pid=$! + trap 'kill "$server_pid" 2>/dev/null; wait "$server_pid" 2>/dev/null || true' EXIT + for _ in {1..120}; do + if curl --fail --silent http://127.0.0.1:3000/eve/v1/health >/dev/null; then break; fi + if ! kill -0 "$server_pid" 2>/dev/null; then cat "$server_log"; exit 1; fi + sleep 0.5 + done + if ! pnpm exec eve eval --verbose --exclude-tag real-model --max-concurrency "$EVALS_PER_LEG" \ + --url http://127.0.0.1:3000; then + echo "::warning::Some evals failed; failures are recorded as benchmark data." + fi + env: + WORLD: ${{ matrix.world }} + # Only non-local legs select a world; fixtures read an empty value + # as a package name, so local legs must leave it unset. + EVE_E2E_WORKFLOW_WORLD: ${{ matrix.world_package }} + + - name: Stage leg artifact + if: always() + env: + LEG_ID: ${{ matrix.leg_id }} + FIXTURE_DIR: ${{ matrix.dir }} + PLANNED_SHA: ${{ needs.plan.outputs.sha }} + MODEL_ID: ${{ matrix.model_id }} + WORLD: ${{ matrix.world }} + ATTEMPT: ${{ matrix.attempt }} + RUNNER_OS_NAME: ${{ runner.os }} + run: bash .github/scripts/stage-benchmark-leg.sh + + - name: Upload leg artifact + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: benchmark-leg-${{ matrix.leg_id }} + path: ${{ runner.temp }}/leg + if-no-files-found: error + retention-days: 1 + + # Runs trusted code from the planned main commit, never code a leg uploaded. + # Planned legs without an artifact become `gap` records. + publish: + name: publish + needs: [plan, run-live, run-mock] + if: always() && needs.plan.result == 'success' && needs.plan.outputs.run == 'true' + runs-on: ubuntu-latest + steps: + - name: Checkout planned commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.plan.outputs.sha }} + persist-credentials: false + + - name: Setup pnpm + uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: .nvmrc + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Download plan + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: benchmark-plan + path: ${{ runner.temp }}/plan + + - name: Prepare leg directory + run: mkdir -p "$RUNNER_TEMP/legs" + + - name: Download leg artifacts + # No match is valid (every leg failed to upload); records turn that into gaps. + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: benchmark-leg-* + path: ${{ runner.temp }}/legs + + - name: Derive records + run: node scripts/eval-benchmark/records.mjs "$RUNNER_TEMP/plan/plan.json" "$RUNNER_TEMP/legs" "$RUNNER_TEMP/records.jsonl" + + - name: Publish to Blob + env: + EVE_BENCHMARK_BLOB_READ_WRITE_TOKEN: ${{ secrets.EVE_BENCHMARK_BLOB_READ_WRITE_TOKEN }} + # TEMPORARY: keep pull request test runs out of the runs/ prefix ingest reads. + EVE_BENCHMARK_BLOB_ROOT: ${{ github.event_name == 'pull_request' && 'preview/runs' || 'runs' }} + run: node scripts/eval-benchmark/publish.mjs "$RUNNER_TEMP/plan/plan.json" "$RUNNER_TEMP/legs" "$RUNNER_TEMP/records.jsonl" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9cc00f59d8..2a3b1ff8c7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,6 +19,7 @@ jobs: release: runs-on: ubuntu-latest permissions: + actions: write contents: write packages: write pull-requests: write @@ -187,3 +188,12 @@ jobs: if [[ -n "$version" ]]; then gh release edit "eve@$version" --latest fi + + # A workflow_run trigger cannot see whether changesets actually + # published, so dispatch the benchmark here to give every release a + # data point. GITHUB_TOKEN may trigger workflow_dispatch. + - name: Benchmark the released eve version + if: steps.changesets.outputs.published == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh workflow run eval-benchmark.yml --repo "$GITHUB_REPOSITORY" --ref main diff --git a/docs/evals/running.mdx b/docs/evals/running.mdx index 4c5242e6fd..095f1f6197 100644 --- a/docs/evals/running.mdx +++ b/docs/evals/running.mdx @@ -41,7 +41,7 @@ With `--verbose`, eve prefixes each line with its eval ID. It streams authored ` ## Artifacts -Each run drops artifacts under `.eve/evals//`: a run `summary.json`, a `results.jsonl` index, and per-eval assertion results, verdicts, captured event streams, and `t.log` lines under `evals/`. The console output stays tight on purpose; when an eval fails, the artifact has the full story. +Each run drops artifacts under `.eve/evals//`: a run `summary.json`, a `results.jsonl` index, and per-eval assertion results, verdicts, start and completion times, captured event streams, and `t.log` lines under `evals/`. The console output stays tight on purpose; when an eval fails, the artifact has the full story. ## CI diff --git a/e2e/benchmark.json b/e2e/benchmark.json new file mode 100644 index 0000000000..a5f1c9e76a --- /dev/null +++ b/e2e/benchmark.json @@ -0,0 +1,20 @@ +{ + "$comment": "Registry for the model benchmark workflow (.github/workflows/eval-benchmark.yml), separate from e2e/matrix.json so adding a benchmark model never adds a CI check. `models` are slots: a stable `name` (`-high`, `-mid`, and `-low` tiers per major lab where the lab offers a current model for the tier, plus each open-weight family's main model; `openai-low` is eve's default model) and the gateway `id` that currently fills it. Bump a slot's `id` when its lab ships a successor; records keep both, and the pinned release marks the change. Leave out service-tier variants (`-fast`) unless they are eve's default. Each slot runs every listed fixture `attempts` times against the local world; each id must exist in the AI Gateway catalog, which pins its release. `mockWorlds` run each fixture once with mock models (`local`, or a world with a package in e2e/matrix.json). `judge` is fixed per registry revision. `concurrency.legs` caps live legs running at once (gateway load); `concurrency.evalsPerLeg` is passed to `eve eval --max-concurrency` on every leg, overriding a fixture's own maxConcurrency, so evals do not share a runner. Both change measured latency, and each run's manifest records them. See research/model-benchmark-tracking.md.", + "models": [ + { "name": "openai-high", "id": "openai/gpt-6.1-sol" }, + { "name": "openai-low", "id": "openai/gpt-6-luna-fast" }, + { "name": "anthropic-high", "id": "anthropic/claude-opus-5.5" }, + { "name": "anthropic-mid", "id": "anthropic/claude-sonnet-5.5" }, + { "name": "anthropic-low", "id": "anthropic/claude-haiku-5.5" }, + { "name": "google-mid", "id": "google/gemini-3.8-flash" }, + { "name": "google-low", "id": "google/gemini-3.5-flash-lite" }, + { "name": "open-kimi", "id": "moonshotai/kimi-k3" }, + { "name": "open-glm", "id": "zai/glm-5.3" }, + { "name": "open-minimax", "id": "minimax/minimax-m3" } + ], + "fixtures": ["agent-tools", "agent-subagents", "agent-tasks"], + "mockWorlds": ["local", "postgres"], + "attempts": 3, + "concurrency": { "legs": 8, "evalsPerLeg": 1 }, + "judge": "openai/gpt-5.6-luna" +} diff --git a/e2e/fixtures/e2e-config/src/judge.ts b/e2e/fixtures/e2e-config/src/judge.ts index 3591e00db8..a80fec838c 100644 --- a/e2e/fixtures/e2e-config/src/judge.ts +++ b/e2e/fixtures/e2e-config/src/judge.ts @@ -2,10 +2,13 @@ import { createOpenAI } from "@ai-sdk/openai"; type JudgeModel = ReturnType["decisionModel"]>; -/** Shared fixture judge, independent of the agent matrix, until CI has access to Jev. */ +/** + * Shared fixture judge, independent of the agent matrix, until CI has access to Jev. + * `EVE_E2E_JUDGE_MODEL` lets the benchmark workflow pin the judge from e2e/benchmark.json. + */ export function e2eJudgeModel(): JudgeModel { return createOpenAI({ apiKey: process.env.AI_GATEWAY_API_KEY, baseURL: "https://ai-gateway.vercel.sh/v1", - }).decisionModel("openai/gpt-5.6-luna"); + }).decisionModel(process.env.EVE_E2E_JUDGE_MODEL ?? "openai/gpt-5.6-luna"); } diff --git a/package.json b/package.json index a8ecd504c0..fbe55d405c 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "test": "pnpm test:unit && pnpm test:integration", "test:e2e": "turbo run test:e2e --concurrency=1", "test:webchat": "pnpm --filter eve run test:webchat", + "test:eval-benchmark": "node --test scripts/eval-metrics/*.test.mjs scripts/eval-benchmark/*.test.mjs", "test:framework-fixtures": "pnpm --filter eve run test:framework-fixtures", "test:integration": "turbo run test:integration --filter=eve && turbo run test:integration --filter=\"!eve\"", "test:scenario": "turbo run test:scenario", @@ -37,6 +38,7 @@ "@changesets/cli": "3.0.1", "@microsoft/api-extractor": "7.59.0", "@types/node": "catalog:", + "@vercel/blob": "2.8.0", "js-yaml": "4.3.2", "oxfmt": "0.66.0", "oxlint": "1.81.0", diff --git a/packages/eve/src/evals/runner/artifacts.test.ts b/packages/eve/src/evals/runner/artifacts.test.ts index 587ad8ab5f..90ffb84e21 100644 --- a/packages/eve/src/evals/runner/artifacts.test.ts +++ b/packages/eve/src/evals/runner/artifacts.test.ts @@ -61,6 +61,18 @@ describe("eval artifacts", () => { }); }); + it("records each eval's start and completion time in every JSON artifact", async () => { + await writeArtifacts("/tmp/eve-evals", skippedSummary()); + + const timing = { + startedAt: "2026-01-01T00:00:00.000Z", + completedAt: "2026-01-01T00:00:01.000Z", + }; + expect(writtenJson("/tmp/eve-evals/summary.json")).toMatchObject({ evals: [timing] }); + expect(writtenJson("/tmp/eve-evals/results.jsonl")).toMatchObject(timing); + expect(writtenJson("/tmp/eve-evals/evals/runtime/skipped.json")).toMatchObject(timing); + }); + it("keeps assertion diagnostics in the run summary", async () => { await writeArtifacts("/tmp/eve-evals", judgedSummary()); diff --git a/packages/eve/src/evals/runner/artifacts.ts b/packages/eve/src/evals/runner/artifacts.ts index 8d6a854828..9ec4ebdbc7 100644 --- a/packages/eve/src/evals/runner/artifacts.ts +++ b/packages/eve/src/evals/runner/artifacts.ts @@ -76,6 +76,8 @@ function buildSummaryArtifact(summary: EveEvalRunSummary): Record { assertions: result.assertions, error: result.error, skipReason: result.skipReason, + startedAt: result.startedAt, + completedAt: result.completedAt, traceContexts: result.result.traceContexts, }; } @@ -112,6 +116,8 @@ function buildEvalArtifact(result: EveEvalResult): Record { assertions: result.assertions, error: result.error, skipReason: result.skipReason, + startedAt: result.startedAt, + completedAt: result.completedAt, }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7224acb9ac..5cb48fe0b5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -268,6 +268,9 @@ importers: '@types/node': specifier: 'catalog:' version: 24.13.3 + '@vercel/blob': + specifier: 2.8.0 + version: 2.8.0 js-yaml: specifier: 4.3.2 version: 4.3.2 diff --git a/research/model-benchmark-tracking-implementation.md b/research/model-benchmark-tracking-implementation.md new file mode 100644 index 0000000000..815d91030c --- /dev/null +++ b/research/model-benchmark-tracking-implementation.md @@ -0,0 +1,341 @@ +--- +issue: "Unassigned — tracking issue needed before implementation" +status: in-progress +last_updated: "2026-10-05" +--- + +# Model benchmark tracking: implementation plan + +This plan implements the eve-repo side of +[`model-benchmark-tracking.md`](./model-benchmark-tracking.md). That side +covers execution, metric derivation, the record contract, and publication to +Blob. The internal ingest/dashboard repository is out of scope here, except +for the contract it consumes. + +## Gaps found against the current code + +These need resolving (or the design doc amending) before or during +implementation: + +1. **Per-eval timing is not persisted.** `EveEvalResult` has `startedAt` and + `completedAt`. `writeArtifacts` (`packages/eve/src/evals/runner/artifacts.ts`) + drops both from `summary.json`, `results.jsonl`, and the per-eval detail + file. "Eval wall ms" needs them. This is the only change to the published + `eve` package (patch changeset). +2. **The measurement primitives are unmerged.** `captureSessions`, + `completedTurns`, `totalModelCost`, and the `Measurement` shape live on + `prha/eval-latency` under `scripts/eval-experiments/measurements/`. + Decision: M1 moves them to `scripts/eval-metrics/` on `main` now, and + `prha/eval-latency` rebases onto that. +3. **Some event names in the doc don't match the protocol.** + `protocol/message.ts` defines: + - human wait: `input.requested` → `input.resolved`, + `approval.candidate` → `approval.settled`, and + `authorization.required` → `authorization.completed` (there is no + `approval.*` family beyond these) + - tool time: `actions.requested` → `action.result`, matched by `callId` + - task time: `task.started` → `task.settled` +4. **Subagent cost split.** `step.completed` events exist only for captured + sessions. Remote and unattached delegated sessions report cost only + through the primary session's rolled-up `usage` on + `session.waiting`/`turn.waiting`/`session.completed`. Define it as: + - primary cost = Σ primary-session `step.completed.usage.costUsd` + - total cost = latest rolled-up `usage.costUsd` + - subagent cost = total − primary + + If either side is missing, the value is `unavailable`. + +5. **There is no attempts primitive.** `eve eval` has no repeat flag. + Attempts become a matrix dimension: each attempt is its own job and its + own `.eve/evals//` tree. That also spreads attempts across + runners and time. +6. **`eval_digest` is undefined.** Proposed definition: sha256 over the git + tree/blob ids of the eval file, its fixture's `evals/evals.config.ts`, + `agent/`, and `package.json`, plus `e2e/fixtures/e2e-config`. All are + read with `git rev-parse HEAD:`, so the digest is cheap, + deterministic, and independent of the eve version. +7. **The Vercel mock world needs Vercel credentials.** Deploying to the + Vercel world needs `VERCEL_TOKEN` in a run job, which conflicts with + "run job holds `AI_GATEWAY_API_KEY` only", and preview timings include + deploy-network effects. Phase 1 mock legs therefore cover the `local` and + `postgres` worlds. A Vercel mock leg can follow, as its own job with only + Vercel credentials. +8. **Release trigger.** Across workflows, `workflow_run` cannot see whether + `changesets/action` actually published. Instead, add a final step to + `release.yml`, gated on `steps.changesets.outputs.published == 'true'`, + that runs `gh workflow run eval-benchmark.yml --ref main`. This works + with `GITHUB_TOKEN`, which may trigger `workflow_dispatch`, and needs + `actions: write`. + +## Layout + +```text +e2e/benchmark.json model registry (separate from matrix.json) +scripts/eval-metrics/ shared, dependency-free, node --test + measurements/{events,lifecycle,metrics}.mjs moved from eval-experiments + standard.mjs standard metric set (metrics_version) + gaps.mjs gap / outcome classification + bundles/self-modification.mjs first fixture bundle +scripts/eval-benchmark/ + plan.mjs registry + catalog → matrix + catalog.mjs AI Gateway id → release resolution + digest.mjs eval_digest + records.mjs artifacts → records.jsonl + record.schema.json schema_version 1 (the cross-repo contract) + publish.mjs validate + immutable Blob writes + *.test.mjs +.github/workflows/eval-benchmark.yml +``` + +`scripts/eval-experiments` (from `prha/eval-latency`) imports from +`scripts/eval-metrics`, so the two tools share one implementation of every +primitive. Both trees are plain `.mjs` with JSDoc types, tested through +`node --test` in the existing CI step (extend its glob). Nothing is added to +`packages/eve`. + +## Milestones + +Each milestone is one PR and is independently useful. + +### M1: Shared measurement primitives + +- Move `events.mjs`, `lifecycle.mjs`, `metrics.mjs`, their tests, and the + `Measurement` type (`measured` | `unavailable` | `not-applicable`) from + `prha/eval-latency` into `scripts/eval-metrics/`, preserving authorship + (`git checkout prha/eval-latency -- `, then `git mv`). +- Rebase `prha/eval-latency` onto M1. Delete its + `scripts/eval-experiments/measurements/` and import from + `scripts/eval-metrics/` instead. +- Add the missing primitives the standard set needs: + - interval pairing by key (`callId`, `taskId`, input batch id) + - interval union, for framework ms + - first-delta-in-step, for time to first token +- Tests: fixture event arrays covering partial, ambiguous, overlapping, and + out-of-order captures. +- **Exit:** `prha/eval-latency`'s self-modification metrics run unchanged on + top of the shared module. + +### M2: Persist per-eval timing (published `eve`) + +- Add `startedAt` and `completedAt` to `buildSummaryArtifact` eval entries, + `buildResultLine`, and `buildEvalArtifact`. +- Update `artifacts.test.ts`, plus a patch changeset ("eval artifacts now + record each eval's start and completion time"). +- **Exit:** `.eve/evals//evals/.json` carries both fields. + +### M3: Standard metric derivation (`standard.mjs`, `gaps.mjs`) + +- `deriveAttemptMetrics(evalArtifact) → { metrics_version, metrics: Record }` + for every row of the design doc's standard-set table. It reads `sessions[]` + from the detail JSON, not the primary-only `events.ndjson`. +- Rules from the design doc: + - human wait is excluded from latency aggregates + - a missing `costUsd` on any step makes cost `unavailable`, never zero + - framework ms = Σ turn time − |union(model, tool, task, compaction, human-wait)| +- Gap classification: + - error before the first `step.completed` + - eval timeout with zero completed steps + - infra failure codes from `step.failed`/`session.failed` + + Keep the list of gap codes explicit and tested. + +- Tests: golden derivations over committed, trimmed event fixtures taken from + real `agent-tools` / `agent-subagents` / `agent-tasks` runs. These are + small JSON files under `scripts/eval-metrics/fixtures/`, not + `packages/eve/test/fixtures/`. +- **Exit:** running the script over a local `.eve/evals` tree prints a full + metric set with coverage. + +### M4: Record contract (`records.mjs`, `record.schema.json`) + +- One record per (fixture, eval, model, world, attempt). Fields: + - keys: `github_run_id`, `run_attempt`, `fixture`, `eval_id`, `model_id`, + `world`, `attempt` (`world` is in the key because the mock legs of + different worlds share `model_id: "mock"`) + - identity: `eval_digest`, `model_release`, `judge_model`, `world` + - x-axis: `sha`, `eve_version` (from `runtimeIdentity.eveVersion`) + - context: runner OS, `started_at` + - correctness: verdict and assertions, with per-assertion name, severity, + score, and passed + - `outcome` (`completed` | `timed_out` | `skipped` | `parked` | `gap`) + - `metrics_version`, `metrics`, `bundles` + - `artifact_path`: relative path of the raw tree in Blob +- Validate against the JSON Schema in tests (hand-rolled validator; no + new dependency). +- **Exit:** the schema is reviewed by the internal-repo owner. This is the + only cross-repo contract. + +### M5: Registry, catalog, plan (`benchmark.json`, `plan.mjs`, `catalog.mjs`, `digest.mjs`) + +- `e2e/benchmark.json` as in the design doc. Fixtures listed there must + exist and have `evals/`; reuse `discoverEvalIds` from + `.github/scripts/discover-e2e-fixtures.mjs` (export it) rather than + duplicating. +- `catalog.mjs` fetches the AI Gateway model catalog once. If any registry + id is missing, it fails with a precise message naming the id. Otherwise + it emits `{ id, release }`. Confirm which catalog field carries the + release date, and mirror eve-bench's pinning logic. +- `plan.mjs` emits: + - `live_matrix`: fixture × model × attempt + - `mock_matrix`: fixture × world, one attempt each + - the resolved registry snapshot (models, releases, judge), passed to + publish as an artifact + + Dispatch inputs narrow models, fixtures, and attempts. + +- Nightly skip: compare `main` HEAD against the head SHA of the last + successful `eval-benchmark.yml` run (`gh run list --workflow +eval-benchmark.yml --branch main --status success -L1`). No Blob read + needed. +- **Exit:** `node scripts/eval-benchmark/plan.mjs` prints a valid matrix + locally. + +### M6: Workflow (`eval-benchmark.yml`) + +- Triggers: `schedule` (nightly), `workflow_dispatch` (inputs: `models`, + `fixtures`, `attempts`), and the dispatch from `release.yml` (see gap 8). + No `pull_request`. +- `plan` fails unless `github.ref == 'refs/heads/main'`. +- `run-live` (`continue-on-error`, `fail-fast: false`, env + `AI_GATEWAY_API_KEY` + `EVE_E2E_MODEL`) reuses the e2e-local setup steps: + - build `eve` and the extension fixtures, then run `e2e:prepare` + - `eve eval` with no tag filter, so benchmark evals are included once + phase 2 adds them (no `--strict`; failures are data) + - upload the single `.eve/evals/` tree plus `meta.json` (`sha`, + `attempt`, `model`, `world`, `runner.os`) with `retention-days: 1` + + Factor the shared setup into a composite action only if duplication + becomes painful. + +- `run-mock` follows the same shape with `EVE_E2E_MODEL=mock` and + `--exclude-tag real-model`. The postgres leg reuses the e2e-postgres + service container and env. +- `publish` runs `needs: [plan, run-live, run-mock]` with `if: always()`: + - checks out `main` at the planned SHA + - downloads every artifact and validates the layout + - runs `records.mjs`, then `publish.mjs` + + It is the only job with `EVE_BENCHMARK_BLOB_READ_WRITE_TOKEN`. Missing + legs become `gap` records (outcome `gap`, reason `job-missing`), so + coverage stays honest. + +- `concurrency: eval-benchmark-main`, `cancel-in-progress: false`. +- **Exit:** a manual dispatch with one fixture × one model × one attempt + publishes to Blob end to end. + +### M7: Publication (`publish.mjs`) + +- Blob layout, all private and immutable (`allowOverwrite: false`), using + the `putImmutable*` pattern from `apps/package-artifacts/scripts/build.mjs`: + + ```text + runs///manifest.json sha, eve_version, registry snapshot, schema_version, record count + runs///records.jsonl + runs///raw/////... raw leg artifact + ``` + + Write `manifest.json` last, so ingest treats its presence as "run + complete". + +- Add `@vercel/blob` as a root `devDependency`, pinned to the version + `packages/eve` already uses (2.8.0); `apps/package-artifacts`' 2.4.0 would + re-resolve optional peers across the workspace lockfile. This keeps `eve`'s + runtime dependencies unchanged. +- **Exit:** re-running publish for the same run id with identical content + is a no-op, and with different content it fails loudly. + +### M8: Phase 1 rollout + +- Seed `e2e/benchmark.json` with `agent-tools`, `agent-subagents`, and + `agent-tasks`. Before seeding, run each fixture under every registry model + via dispatch, and drop any eval that is flaky for non-model reasons. +- Enable the nightly schedule. Watch cost for a week, then settle the + budget open question with real numbers. +- Hand off the schema to the internal repo. + +### M9: Phase 2 benchmark lane (later) + +- Add `evals/benchmark/` support: the `benchmark` tag is required there and + forbidden elsewhere. Add it as a new rule in `scripts/guard-invariants.mjs` + with a why/fix message. +- `e2e-local`, `e2e-vercel`, and `e2e-postgres` add `--exclude-tag +benchmark`. +- `discover-e2e-fixtures.mjs` excludes `benchmark/*` from `modelShards` + completeness checks. +- Add the self-modification fixture bundle (`bundles/self-modification.mjs`) + once `eval-experiments` and this work share M1. + +## Validation + +- `node --test` over `scripts/eval-metrics` and `scripts/eval-benchmark` in + the existing CI step. These tests carry most of the correctness risk + (derivation and gap classification). +- `artifacts.test.ts` for M2. +- End to end: manual `workflow_dispatch` runs on `main`. There is no PR + trigger, so workflow changes are validated post-merge by dispatch. Keep + M6 small and land it behind an empty `fixtures` list if needed. + +## Implementation notes + +Decisions taken while implementing M1–M7 that refine the plan above: + +- **Root sessions.** Latency, attribution, cost, tokens, and counts read + the sessions the eval opened (not linked by `agent.started` or a subagent + `invocation`). Repeated captures of one session merge by `sessionId`. + Reliability counts read every captured session. +- **Attribution is a partition.** Inside root turns, time goes to the first + matching bucket: human wait, task, tool, compaction, model step; the rest + is framework ms. The parts add up to turn ms. Task outranks tool so a + `task_wait` call blocking on delegated work counts as task time, and tool + outranks model because in-process tools run inside their step. +- **Abandoned work closes at its turn's end.** A step cut off by a session + limit or a cancelled turn's open actions end at the turn's terminal event + instead of making the attempt unavailable. +- **Human-wait pairing.** `approval.settled` also settles approvals that + only went through `input.requested`, so only pending `approval.candidate` + events open approval intervals. `authorization.required` does not always + carry `attemptId`, so sign-ins pair by turn and connection name. +- **Expected evals.** Each run leg uploads `eve eval --list --json` output + under its tag filters. A leg that uploaded nothing falls back to the + fixture's discovered eval files, so a missing mock leg also reports gaps + for its `real-model` evals. +- **Concurrency.** `e2e/benchmark.json` `concurrency` sets `max-parallel` + for live legs (default 8) and `eve eval --max-concurrency` for every leg + (default 1). Live legs are ordered attempt → fixture → model, so each wave + spans models. This keeps gateway load to about one session per model and + stops evals from sharing a runner, which skews framework ms and event + timestamps. Timings from e2e-local PR runs put a full 10-model run at about 65 + minutes. Every manifest records the setting, because changing it changes + measured latency. +- **Model slots.** Registry entries are slots (high, mid, and low tiers + per major lab where the lab has a current model for the tier, plus each + open-weight family's main model; `openai-low` is eve's default model). A slot's `id` moves to its lab's successor model. + Records carry `model_slot` to follow a tier across releases; `model_id` + and `model_release` stay in the comparability identity. +- **Model-independent evals.** Some `agent-subagents` evals script every + model call with eve's mock models, even on live legs. A live-leg eval + whose model steps all ran on another model is excluded from records, so + it does not count toward that model's pass rate or latency. The mock + track still measures it. Evals with no model step stay, so they can + surface as gaps. +- **Judge.** `e2eJudgeModel()` reads `EVE_E2E_JUDGE_MODEL`, which the + workflow sets from the registry, so `judge_model` is the judge that ran. +- **Model release.** The AI Gateway `/v1/models` entry's `released` (Unix + seconds), as a `YYYY-MM-DD` date. eve-bench's pinning logic is private, + so this has not been checked against it. +- **Golden fixtures** are trimmed mock-model captures of `agent-tools` and + `agent-subagents`. Every `agent-tasks` eval is `real-model`, so a live + capture should be added after the first benchmark run. + +## Open questions for this plan + +- Gap rule: is the proposed split right? Proposal: + - gap: `MODEL_CALL_FAILED` with an HTTP 429/5xx or network-level + `details`, and `UND_ERR_*_TIMEOUT` + - real failure: `MODEL_CALL_FAILED` with any other 4xx (for example, + context too long), and `OUTPUT_SCHEMA_NOT_FULFILLED` + - excluded from scoring: CI or setup errors before the eval sends its + first message +- Blob store: give this its own store and token (`EVE_BENCHMARK_BLOB_*`) + rather than sharing eve-bench's. That store's layout lives in the private + eve-bench action and isn't visible from this repo. diff --git a/research/model-benchmark-tracking.md b/research/model-benchmark-tracking.md new file mode 100644 index 0000000000..c85ede855c --- /dev/null +++ b/research/model-benchmark-tracking.md @@ -0,0 +1,286 @@ +--- +issue: "Unassigned — tracking issue needed before implementation" +status: proposed +last_updated: "2026-10-05" +--- + +# Model benchmark tracking + +## Decision and scope + +Track correctness, latency, and cost of fixture evals over time, across +models and eve versions. The goal is to answer questions such as: + +- Did eval X get a step change in latency across all models starting in eve + version Y? +- Is model A 20% faster than model B on eval X at roughly the same cost? + +Runs on trusted `main` revisions publish immutable, versioned records to +private Vercel Blob. An internal Vercel repository ingests those records into +Postgres and serves a dashboard. This repository owns execution and the record +contract. The internal repository owns the database schema, ingestion, and UI. + +This is internal tooling, not a public eve API. It adds no required checks, +changes no PR workflow, and ships nothing in the published `eve` package. + +Out of scope: harness comparisons (eve-bench owns those), coding-agent +authoring benchmarks (`apps/benchmarks` owns those), and per-PR regression +gating. + +## Prior art + +- **`vercel/agents` (omniagent)**: separate lanes, `evals/pr/*` and + `evals/benchmark/*`, each with its own config. The benchmark lane runs on a + 12-hour cron and on dispatch, emits `results.jsonl`/`summary.json`/ + `metrics.json`, and keeps them only as 7-day artifacts. Reporting runs from + trusted code, never from the evaluated revision. +- **`vercel/internal-agents` (e0)**: an eve `EvalReporter` measures cost, + tokens, wall/active time, steps, and compactions per attempt. It keys + comparability on model, reasoning, eve version, and task digest, and keeps + an append-only baseline history compared against the trailing median. +- **`vercel-labs/eve-bench`**: publishes only from `push`, `schedule`, and + `workflow_dispatch` events into a Blob result store (see + `eve-code-benchmark.yml`). It pins model aliases to their AI Gateway catalog + release, and it treats infrastructure failures as gaps rather than zeros. +- **Instruction latency experiments** (`prha/eval-latency`, + `research/eval-latency-experiments.md`): derive measurements from captured + eval event streams after execution. A measurement is either measured, with + event evidence, or unavailable, with a reason. + +This plan adopts e0's measurement set, the latency experiments' measurement +shape and event-derived primitives, and eve-bench's Blob publication, +model-version, and gap rules. It defers omniagent's separate benchmark lane to +phase 2. + +GitHub Actions artifacts are not the durable store. `e2e-local` uploads +`.eve/evals` only on failure with 7-day retention, and GitHub caps retention +at 90 days. Scraping GitHub would also add polling, token management, and +rate limits to the ingest path. + +## Architecture + +```text +eve repo internal repo +───────────────────────────────────────────── ───────────────────────────── +plan ─► run (fixture × model × attempt) ─► publish ─► Blob ─► ingest ─► Postgres ─► dashboard + AI_GATEWAY_API_KEY only trusted main cron (rebuildable) + Blob token only +``` + +- **Blob is the system of record.** Each run writes raw `.eve/evals` trees plus + a `records.jsonl` that conforms to a versioned schema. Blob objects are + immutable and keyed by `github_run_id`/`run_attempt`. +- **Postgres is a projection.** Ingest is idempotent and can rebuild the + database from Blob. New metrics can be backfilled by re-deriving them from + raw artifacts. +- **The record schema is the only cross-repo contract.** Every record carries + `schema_version`. The internal repo can change its tables and UI without + touching eve. + +## Phases + +**Phase 1 — existing evals.** Run the current fixture evals that already work +under live models, on every registry model, with repeated attempts. Even when +correctness is saturated, latency and cost are informative. Also publish the +mock-model world suites (see below). This answers both motivating questions +without authoring new evals. + +**Phase 2 — benchmark lane.** Add evals that discriminate between models on +correctness. A fixture opts in with `evals/benchmark/`. Every eval there +carries the `benchmark` tag, the CI lane runs `--exclude-tag benchmark`, and +`pnpm guard:invariants` enforces the tag/directory pairing. Benchmark evals +reuse the fixture's agent and `evals.config.ts`. + +## Model registry + +`e2e/benchmark.json` is separate from `e2e/matrix.json`, so adding a +benchmark model never adds a CI check: + +```json +{ + "models": [ + { "name": "openai-sol", "id": "openai/gpt-6-sol" }, + { "name": "anthropic-opus", "id": "anthropic/claude-opus-5.5" }, + { "name": "anthropic-sonnet", "id": "anthropic/claude-sonnet-5.5" }, + { "name": "google-flash", "id": "google/gemini-3.8-flash" }, + { "name": "kimi", "id": "moonshotai/kimi-k3" } + ], + "fixtures": ["agent-tools", "agent-subagents", "agent-tasks"], + "attempts": 3, + "judge": "openai/gpt-5.6-luna" +} +``` + +Fixtures already resolve any gateway id through `EVE_E2E_MODEL`. Before any +attempt runs, the workflow resolves each id against the AI Gateway catalog and +records its release date. If an id is missing from the catalog, the run fails +before it starts. The judge model is fixed per registry revision, because +score trends are meaningless when the judge changes underneath them. + +## Workflow + +`eval-benchmark.yml` never runs on `pull_request`. It has three triggers: + +- **Nightly `schedule`** against `main` HEAD. It skips if HEAD matches the last + successfully published run's SHA. +- **Release.** It runs after `release.yml` publishes a new `eve` version, so + every release has at least one data point. +- **`workflow_dispatch`**, with optional `models`, `fixtures`, and `attempts` + inputs to narrow cost. + +Jobs: + +- **plan**: fails unless `github.ref == refs/heads/main`. Emits the + fixture × model × attempt matrix from `e2e/benchmark.json`. +- **run**: `continue-on-error`. It holds `AI_GATEWAY_API_KEY` only and uploads + `.eve/evals` as a short-lived handoff artifact. +- **publish**: checks out trusted `main`. It validates artifacts, derives + `records.jsonl` from them, and writes everything to Blob. This is the only + job that holds the Blob token. + +Cost is bounded by fixtures × models × attempts. At three attempts nightly, +budget is the main tuning knob. Dispatch can narrow any dimension. + +## Metrics + +The publish job derives all metrics from raw eval artifacts, not from a +reporter inside the evaluated revision. Every eval session captures its full +event stream, and every event carries a durable `meta.at` timestamp. Per-step +usage, `finishReason`, and the gateway `generationId` arrive on +`step.completed`. The standard set needs no runtime changes, and new metrics +can be backfilled from Blob. + +### Measurement shape + +Each metric value uses the measurement shape from the instruction latency +experiments (`research/eval-latency-experiments.md`): + +```ts +type Measurement = + | { status: "measured"; value: number; evidence?: { sessionId: string; eventId: string }[] } + | { status: "unavailable" | "not-applicable"; reason: string }; +``` + +A metric is `unavailable` when its evidence is partial or ambiguous, such as a +step without cost or a turn without a terminal event. It is never zero. +Medians exclude unavailable values. Coverage (measured / attempts) is reported +next to every aggregate. + +### Standard set + +Every attempt gets the standard set, versioned as `metrics_version`: + +| Dimension | Metric | Derivation | +| ----------- | ------------------- | ------------------------------------------------------------------------------------------ | +| Correctness | verdict, assertions | eval verdict; per-assertion name, severity, score, passed; judge scores | +| Latency | eval wall ms | eval `startedAt` → `completedAt` | +| | turn ms | `turn.started` → `turn.completed`/`failed`/`cancelled`, summed and per turn | +| | time to first token | `step.started` → first `message.appended`/`reasoning.appended` of the step | +| | output tokens/s | step `outputTokens` ÷ (`step.completed` − first delta) | +| Attribution | model ms | Σ `step.started` → `step.completed` | +| | tool ms | Σ `actions.requested` → `action.result`, matched by call id | +| | task ms | Σ `task.started` → `task.settled` | +| | human wait ms | `input.requested`/`approval.*`/`authorization.required` → resolution | +| | compaction ms | `compaction.requested` → `compaction.completed` | +| | framework ms | turn time outside the union of model, tool, task, compaction, and human-wait intervals | +| Cost | cost USD | Σ `step.completed.usage.costUsd`, split into primary and subagent sessions | +| | tokens | input, output, cache-read, cache-write; cache hit ratio = cache-read ÷ input | +| | peak context | max step `inputTokens` | +| Efficiency | counts | turns per session, steps per turn, tool calls per step, subagent fan-out and depth | +| | compactions, clears | `compaction.completed`, `context.cleared` | +| Reliability | failures | `step.failed`, `turn.failed`, `session.failed` counts by `code`; tool error rate | +| | truncations | steps with `finishReason: "length"` | +| | outcome | timed out, skipped, parked on input, `gap` | +| Context | identity | sha, eve version, workflow world, runner OS, started at, model release, eval digest, judge | + +Framework ms is the attribution metric for eve-version step changes. It +counts the time inside turns when no model call, tool, task, compaction, or +human wait is in flight: turn startup, gaps between steps, and turn +finalization. Human wait is excluded from every latency aggregate so +approval-heavy evals stay comparable. + +Known limits: + +- `meta.at` is stamped at durable write. Model ms therefore includes eve's + streaming writes during a step. +- Coalesced deltas can make time to first token slightly late. +- Judge-model cost is not in the agent's event stream and is not part of + cost USD. + +### Fixture bundles + +A fixture can add a versioned bundle of eval-specific metrics that reuses the +same primitives and measurement shape. One example is the self-modification +bundle's parent-turn-to-final-child-completion. Bundles live next to the +fixture, and their values are namespaced as `.`. + +### Gaps + +An attempt is a **gap**, not a failure, when it errors before any model step +completes or fails for an infrastructure reason. Gaps count toward coverage +but are excluded from pass rates and from latency and cost medians. + +## Mock-model framework track + +The world suites (`e2e-vercel`, `e2e-postgres`) already run every fixture with +deterministic mock models. With no model latency or cost, their timings come +close to pure eve overhead per workflow world. The nightly run includes one +mock-model leg per world. Records are marked `model_id: "mock"` and carry the +same schema. This is the lowest-noise signal for eve-version step changes, +at near-zero cost. + +## Comparability + +The comparability identity is +`(eval_id, eval_digest, model_id, model_release, judge_model, world)`. +Trend lines break wherever the identity changes, and runs with different +identities are never aggregated together. `eve_version` and `sha` are the +x-axis, not part of the identity. The dashboard maps SHAs to release tags so +results can be grouped by eve version. + +Live-model latency is noisy. Comparisons use p50/p90 across at least three +attempts, and `started_at` is retained so provider-side drift (time of day, +incidents) can be inspected rather than attributed to eve. + +## Internal repo (consumer) + +These details are owned outside this repository and are listed only to pin +down the contract: + +- **Ingest**: a Vercel cron lists new Blob prefixes, validates + `schema_version`, and upserts rows keyed by + `(github_run_id, run_attempt, fixture, eval_id, model_id, world, attempt)`. +- **Storage**: Postgres via Vercel Marketplace. Raw artifacts contain + transcripts, so they stay in private Blob and are linked by URL. +- **Dashboard**: Next.js behind Vercel Authentication, reading through a + read-only role. Views: + - Trends: pass rate, p50 cost, p50 wall and framework latency per model over + eve versions, with identity breaks marked. + - Frontier: scatter of latency against cost, or pass rate against cost. + - Eval detail: attempts with links to the GitHub run and Blob artifacts. + +## Alternatives considered + +- **Scrape GitHub artifacts.** Rejected because of retention limits, polling, + and rate limits. Pushing to Blob is simpler and durable. +- **Write Postgres directly from CI.** Rejected because it couples the + database schema to eve and loses raw data needed for backfills. +- **Braintrust / Datadog Experiments.** Their reporters already exist and + are worth a trial on a nightly run. They are weaker at per-model, + per-eve-version trend lines and cost/latency frontiers, which are the + primary views here. +- **Run on every push to `main`.** Rejected as the default because of cost. + Nightly runs and per-release runs give version resolution at bounded spend. + +## Open questions + +- Which fixtures seed phase 1, and which `real-model` evals later move into + the benchmark lane? +- Budget: nightly with 3 attempts, or weekly with 5? +- Should e0's per-attempt record shape be adopted as `schema_version: 1` to + share tooling? +- Can the AI Gateway generation lookup (by `generationId`) provide + provider-side latency to split network and provider time out of model ms? +- Should the eval runner record client send and receive times, to measure + cold start and stream delivery lag? +- Should the dashboard later include `apps/benchmarks` authoring results? diff --git a/scripts/eval-benchmark/catalog.mjs b/scripts/eval-benchmark/catalog.mjs new file mode 100644 index 0000000000..24710d8db1 --- /dev/null +++ b/scripts/eval-benchmark/catalog.mjs @@ -0,0 +1,43 @@ +// Pins benchmark model ids to their AI Gateway catalog release, so a provider +// re-pointing an alias shows up as an identity break instead of a trend. + +export const AI_GATEWAY_MODELS_URL = "https://ai-gateway.vercel.sh/v1/models"; + +/** + * @param {{ name: string, id: string }[]} models + * @param {string} judge + * @param {typeof fetch} [fetchImpl] + * @returns {Promise<{ name: string, id: string, release: string }[]>} + */ +export async function resolveModelReleases(models, judge, fetchImpl = fetch) { + const response = await fetchImpl(AI_GATEWAY_MODELS_URL, { signal: AbortSignal.timeout(30_000) }); + if (!response.ok) { + throw new Error( + `AI Gateway model catalog request failed (${response.status}); cannot pin model releases.`, + ); + } + const body = await response.json(); + if (!Array.isArray(body?.data)) throw new Error("AI Gateway returned an invalid model catalog."); + const catalog = new Map(body.data.map((entry) => [entry?.id, entry])); + + if (!catalog.has(judge)) { + throw new Error( + `AI Gateway catalog has no judge model "${judge}" (e2e/benchmark.json "judge"). Update the id.`, + ); + } + return models.map(({ name, id }) => { + const entry = catalog.get(id); + if (entry === undefined) { + throw new Error( + `AI Gateway catalog has no model "${id}" (e2e/benchmark.json model "${name}"). Update the id or remove the entry.`, + ); + } + // `released` is the catalog's release date in Unix seconds. + if (typeof entry.released !== "number" || !Number.isFinite(entry.released)) { + throw new Error( + `AI Gateway catalog entry "${id}" (e2e/benchmark.json model "${name}") has no release date to pin.`, + ); + } + return { name, id, release: new Date(entry.released * 1000).toISOString().slice(0, 10) }; + }); +} diff --git a/scripts/eval-benchmark/digest.mjs b/scripts/eval-benchmark/digest.mjs new file mode 100644 index 0000000000..b543b7303d --- /dev/null +++ b/scripts/eval-benchmark/digest.mjs @@ -0,0 +1,68 @@ +// `eval_digest`: sha256 over the git object ids of everything that defines an +// eval's behavior apart from eve itself — the eval file, its fixture's +// evals.config.ts, agent/ tree, and package.json, and the shared e2e config. +// Reading object ids at the planned commit keeps it cheap, deterministic, and +// independent of the eve version. +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; + +const SHARED_PATHS = ["e2e/fixtures/e2e-config"]; + +/** + * @param {{ sha: string, fixtureDir: string, evalIds: string[], resolve?: (specs: string[]) => Map }} input + * @returns {Map} eval id → hex digest + */ +export function evalDigests({ sha, fixtureDir, evalIds, resolve = gitObjectIds }) { + const fixturePaths = ["evals/evals.config.ts", "agent", "package.json"].map( + (path) => `${fixtureDir}/${path}`, + ); + // Array-exported evals append segments to their file's id, so try each + // shorter prefix until one names a file. + const candidates = new Map( + evalIds.map((id) => { + const segments = id.split("/"); + return [ + id, + segments.map( + (_, index) => + `${fixtureDir}/evals/${segments.slice(0, segments.length - index).join("/")}.eval.ts`, + ), + ]; + }), + ); + const specs = [ + ...new Set([...SHARED_PATHS, ...fixturePaths, ...[...candidates.values()].flat()]), + ].map((path) => `${sha}:${path}`); + const ids = resolve(specs); + const objectId = (path) => ids.get(`${sha}:${path}`) ?? "missing"; + + const digests = new Map(); + for (const [id, paths] of candidates) { + const evalFile = paths.find((path) => ids.has(`${sha}:${path}`)); + const hash = createHash("sha256"); + for (const path of [ + evalFile ?? `${fixtureDir}/evals/${id}.eval.ts`, + ...fixturePaths, + ...SHARED_PATHS, + ]) + hash.update(`${path}\0${objectId(path)}\n`); + digests.set(id, hash.digest("hex")); + } + return digests; +} + +/** Resolve `:` specs to object ids in one git process; missing paths are omitted. */ +export function gitObjectIds(specs) { + const output = execFileSync("git", ["cat-file", "--batch-check=%(objectname)"], { + input: `${specs.join("\n")}\n`, + encoding: "utf8", + }); + const ids = new Map(); + output + .trimEnd() + .split("\n") + .forEach((line, index) => { + if (!line.endsWith(" missing")) ids.set(specs[index], line.trim()); + }); + return ids; +} diff --git a/scripts/eval-benchmark/plan.mjs b/scripts/eval-benchmark/plan.mjs new file mode 100644 index 0000000000..cd9e88e90b --- /dev/null +++ b/scripts/eval-benchmark/plan.mjs @@ -0,0 +1,278 @@ +// Plans one benchmark run from e2e/benchmark.json: +// +// live_matrix attempt × fixture × model legs on the local world, ordered so +// the legs running together spread across models +// max_parallel, evals_per_leg +// the registry's concurrency caps, which bound gateway load +// and keep evals from sharing a runner +// mock_matrix fixture × mock world legs, one attempt each +// plan.json the resolved registry snapshot (models pinned to their AI +// Gateway release, judge, expected eval ids) plus every leg, +// handed to the publish job +// +// Dispatch inputs narrow the run through BENCHMARK_MODELS and +// BENCHMARK_FIXTURES (comma-separated registry names) and BENCHMARK_ATTEMPTS. +// +// Usage: node scripts/eval-benchmark/plan.mjs [sha] +import { execFileSync } from "node:child_process"; +import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { discoverEvalIds } from "../../.github/scripts/discover-e2e-fixtures.mjs"; +import { resolveModelReleases } from "./catalog.mjs"; + +export const PLAN_SCHEMA_VERSION = 1; +const FIXTURE_ROOTS = ["e2e/fixtures", "apps/fixtures"]; +const NAME = /^[a-z0-9-]+$/; +const MAX_ATTEMPTS = 10; +const MAX_PARALLEL_LEGS = 50; +const MAX_EVALS_PER_LEG = 16; + +/** + * Validate the registry and apply dispatch narrowing. Pure apart from the + * injected fixture lookup, so it is testable without a checkout. + * + * @param {{ + * registry: any, + * matrix: any, + * findFixture: (name: string) => { dir: string, evalIds: string[], packageJson?: any } | undefined, + * narrow?: { models?: string, fixtures?: string, attempts?: string }, + * }} input + */ +export function selectBenchmark({ registry, matrix, findFixture, narrow = {} }) { + const models = namedEntries(registry?.models, "models"); + if (typeof registry?.judge !== "string" || registry.judge.length === 0) + throw new Error('e2e/benchmark.json: "judge" must be a non-empty gateway model id.'); + const attempts = positiveInteger(registry?.attempts, 'e2e/benchmark.json: "attempts"'); + const concurrency = { + legs: boundedInteger( + registry?.concurrency?.legs, + MAX_PARALLEL_LEGS, + 'e2e/benchmark.json: "concurrency.legs"', + ), + evalsPerLeg: boundedInteger( + registry?.concurrency?.evalsPerLeg, + MAX_EVALS_PER_LEG, + 'e2e/benchmark.json: "concurrency.evalsPerLeg"', + ), + }; + const fixtureNames = names(registry?.fixtures, "fixtures"); + const mockWorlds = names(registry?.mockWorlds ?? [], "mockWorlds", { allowEmpty: true }); + const matrixWorlds = new Map((matrix?.worlds ?? []).map((world) => [world.name, world])); + for (const world of mockWorlds) { + // Mock legs boot a local server, so a non-local world must be a package + // the fixture can select (EVE_E2E_WORKFLOW_WORLD), not a deploy target. + if (world !== "local" && matrixWorlds.get(world)?.package === undefined) { + const packaged = [...matrixWorlds.values()].filter((w) => w.package).map((w) => w.name); + throw new Error( + `e2e/benchmark.json: mock world "${world}" must be "local" or a world with a package in e2e/matrix.json (${packaged.join(", ")}).`, + ); + } + } + + const fixtures = fixtureNames.map((name) => { + const found = findFixture(name); + if (found === undefined) { + throw new Error( + `e2e/benchmark.json: fixture "${name}" was not found with an evals/ directory under ${FIXTURE_ROOTS.join(" or ")}.`, + ); + } + if (found.evalIds.length === 0) + throw new Error(`e2e/benchmark.json: fixture "${name}" has no *.eval.ts files.`); + return { name, ...found }; + }); + + const selectedModels = narrowNames(models, narrow.models, "model"); + const selectedFixtures = narrowNames(fixtures, narrow.fixtures, "fixture"); + const selectedAttempts = + narrow.attempts === undefined || narrow.attempts === "" + ? attempts + : positiveInteger(Number(narrow.attempts), "attempts input"); + + return { + judge: registry.judge, + attempts: selectedAttempts, + concurrency, + models: selectedModels, + fixtures: selectedFixtures, + mockWorlds: mockWorlds.map((name) => ({ name, package: matrixWorlds.get(name)?.package })), + }; +} + +/** Expand a selection into run legs. Leg ids are stable artifact-name suffixes. */ +export function planLegs({ models, fixtures, attempts, mockWorlds }) { + // GitHub starts matrix legs in order and cannot cap concurrency per model, + // so model-innermost order keeps each wave of legs spread across models and + // each eval's attempts spread over time. + const live = []; + for (let attempt = 1; attempt <= attempts; attempt += 1) { + for (const fixture of fixtures) { + for (const model of models) { + live.push( + leg({ kind: "live", fixture, model_name: model.name, model_id: model.id, attempt }), + ); + } + } + } + const mock = []; + for (const fixture of fixtures) { + const selectedWorlds = fixture.packageJson?.e2e?.worlds; + for (const world of mockWorlds) { + // `local` is the default world every fixture boots on; other worlds + // follow the fixture's own e2e.worlds selection. + if ( + world.name !== "local" && + Array.isArray(selectedWorlds) && + !selectedWorlds.includes(world.name) + ) + continue; + mock.push( + leg({ kind: "mock", fixture, model_name: "mock", model_id: "mock", world, attempt: 1 }), + ); + } + } + return { live, mock }; +} + +function leg({ kind, fixture, model_name, model_id, world = { name: "local" }, attempt }) { + return { + // Registry names never contain "_", so "__" keeps ids unambiguous. + leg_id: [kind, fixture.name, model_name, world.name, attempt].join("__"), + kind, + fixture: fixture.name, + dir: fixture.dir, + model_name, + model_id, + world: world.name, + world_package: world.package ?? "", + attempt, + }; +} + +function namedEntries(entries, key) { + if (!Array.isArray(entries) || entries.length === 0) + throw new Error(`e2e/benchmark.json: "${key}" must be a non-empty array.`); + const seen = new Set(); + for (const entry of entries) { + if (typeof entry?.name !== "string" || !NAME.test(entry.name)) + throw new Error(`e2e/benchmark.json: every "${key}" entry needs a lowercase-dashed "name".`); + if (typeof entry.id !== "string" || entry.id.length === 0) + throw new Error(`e2e/benchmark.json: "${key}" entry "${entry.name}" needs a gateway "id".`); + if (seen.has(entry.name) || seen.has(entry.id)) + throw new Error(`e2e/benchmark.json: duplicate "${key}" entry "${entry.name}".`); + seen.add(entry.name).add(entry.id); + } + return entries.map(({ name, id }) => ({ name, id })); +} + +function names(entries, key, { allowEmpty = false } = {}) { + if (!Array.isArray(entries) || (!allowEmpty && entries.length === 0)) + throw new Error(`e2e/benchmark.json: "${key}" must be a non-empty array.`); + if (entries.some((entry) => typeof entry !== "string" || !NAME.test(entry))) + throw new Error( + `e2e/benchmark.json: "${key}" entries must be lowercase alphanumerics and dashes.`, + ); + if (new Set(entries).size !== entries.length) + throw new Error(`e2e/benchmark.json: "${key}" contains duplicates.`); + return entries; +} + +function positiveInteger(value, label) { + return boundedInteger(value, MAX_ATTEMPTS, label); +} + +function boundedInteger(value, max, label) { + if (!Number.isInteger(value) || value < 1 || value > max) + throw new Error(`${label} must be an integer from 1 to ${max}.`); + return value; +} + +/** An empty input keeps every entry; otherwise every requested name must exist. */ +function narrowNames(entries, input, label) { + if (input === undefined || input.trim() === "") return entries; + const requested = input + .split(",") + .map((name) => name.trim()) + .filter(Boolean); + if (requested.length === 1 && requested[0] === "none") return []; + const known = new Map(entries.map((entry) => [entry.name, entry])); + const unknown = requested.filter((name) => !known.has(name)); + if (unknown.length > 0) { + throw new Error( + `Unknown benchmark ${label}${unknown.length === 1 ? "" : "s"} ${unknown.join(", ")}; choose from ${[...known.keys()].join(", ")} (or "none").`, + ); + } + return requested.map((name) => known.get(name)); +} + +function findFixtureOnDisk(name) { + for (const root of FIXTURE_ROOTS) { + const dir = join(root, name); + if (!existsSync(join(dir, "evals"))) continue; + const packageJsonPath = join(dir, "package.json"); + return { + dir, + evalIds: discoverEvalIds(join(dir, "evals")), + packageJson: existsSync(packageJsonPath) + ? JSON.parse(readFileSync(packageJsonPath, "utf8")) + : undefined, + }; + } + return undefined; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const [planPath, shaArgument] = process.argv.slice(2); + if (planPath === undefined) { + console.error("Usage: node scripts/eval-benchmark/plan.mjs [sha]"); + process.exit(1); + } + const sha = + shaArgument ?? execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim(); + const selection = selectBenchmark({ + registry: JSON.parse(readFileSync("e2e/benchmark.json", "utf8")), + matrix: JSON.parse(readFileSync("e2e/matrix.json", "utf8")), + findFixture: findFixtureOnDisk, + narrow: { + models: process.env.BENCHMARK_MODELS, + fixtures: process.env.BENCHMARK_FIXTURES, + attempts: process.env.BENCHMARK_ATTEMPTS, + }, + }); + const models = await resolveModelReleases(selection.models, selection.judge); + const { live, mock } = planLegs({ ...selection, models }); + const plan = { + schema_version: PLAN_SCHEMA_VERSION, + sha, + planned_at: new Date().toISOString(), + registry: { + judge: selection.judge, + attempts: selection.attempts, + // Concurrency changes measured latency, so every manifest records it. + concurrency: { + legs: selection.concurrency.legs, + evals_per_leg: selection.concurrency.evalsPerLeg, + }, + models, + mock_worlds: selection.mockWorlds.map((world) => world.name), + fixtures: selection.fixtures.map(({ name, dir, evalIds }) => ({ + name, + dir, + eval_ids: evalIds, + })), + }, + legs: [...live, ...mock], + }; + writeFileSync(planPath, `${JSON.stringify(plan, null, 2)}\n`); + const lines = [ + `live_matrix=${JSON.stringify(live)}`, + `mock_matrix=${JSON.stringify(mock)}`, + `judge=${selection.judge}`, + `max_parallel=${selection.concurrency.legs}`, + `evals_per_leg=${selection.concurrency.evalsPerLeg}`, + ]; + console.error(`Planned ${live.length} live and ${mock.length} mock legs at ${sha}.`); + if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join("\n")}\n`); + else process.stdout.write(`${lines.join("\n")}\n`); +} diff --git a/scripts/eval-benchmark/plan.test.mjs b/scripts/eval-benchmark/plan.test.mjs new file mode 100644 index 0000000000..588ccec1fb --- /dev/null +++ b/scripts/eval-benchmark/plan.test.mjs @@ -0,0 +1,200 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { resolveModelReleases } from "./catalog.mjs"; +import { evalDigests } from "./digest.mjs"; +import { planLegs, selectBenchmark } from "./plan.mjs"; + +const registry = { + models: [ + { name: "alpha", id: "acme/alpha" }, + { name: "beta", id: "acme/beta" }, + ], + fixtures: ["agent-one", "agent-two"], + mockWorlds: ["local", "postgres"], + attempts: 2, + concurrency: { legs: 3, evalsPerLeg: 1 }, + judge: "acme/judge", +}; +const matrix = { + worlds: [{ name: "vercel" }, { name: "postgres", package: "@workflow/world-postgres" }], +}; +const fixtures = { + "agent-one": { dir: "e2e/fixtures/agent-one", evalIds: ["a", "b"] }, + "agent-two": { + dir: "e2e/fixtures/agent-two", + evalIds: ["c"], + packageJson: { e2e: { worlds: ["vercel"] } }, + }, +}; +const select = (overrides = {}, narrow = {}) => + selectBenchmark({ + registry: { ...registry, ...overrides }, + matrix, + findFixture: (name) => fixtures[name], + narrow, + }); + +test("expands fixtures × models × attempts and mock worlds the fixture selects", () => { + const selection = select(); + assert.deepEqual(selection.concurrency, { legs: 3, evalsPerLeg: 1 }); + const { live, mock } = planLegs(selection); + assert.equal(live.length, 2 * 2 * 2); + // Models vary fastest so legs started together spread across models, and + // each eval's attempts are spread over the run. + assert.deepEqual( + live.slice(0, 5).map((leg) => leg.leg_id), + [ + "live__agent-one__alpha__local__1", + "live__agent-one__beta__local__1", + "live__agent-two__alpha__local__1", + "live__agent-two__beta__local__1", + "live__agent-one__alpha__local__2", + ], + ); + assert.deepEqual(live[0], { + leg_id: "live__agent-one__alpha__local__1", + kind: "live", + fixture: "agent-one", + dir: "e2e/fixtures/agent-one", + model_name: "alpha", + model_id: "acme/alpha", + world: "local", + world_package: "", + attempt: 1, + }); + assert.equal(new Set(live.map((leg) => leg.leg_id)).size, live.length); + assert.deepEqual( + mock.map((leg) => [leg.leg_id, leg.world_package]), + [ + ["mock__agent-one__mock__local__1", ""], + ["mock__agent-one__mock__postgres__1", "@workflow/world-postgres"], + ["mock__agent-two__mock__local__1", ""], + ], + ); +}); + +test("dispatch inputs narrow every dimension and reject unknown names", () => { + const selection = select({}, { models: "beta", fixtures: "agent-two", attempts: "1" }); + assert.deepEqual( + planLegs(selection).live.map((leg) => leg.leg_id), + ["live__agent-two__beta__local__1"], + ); + assert.deepEqual(planLegs(select({}, { fixtures: "none" })), { live: [], mock: [] }); + assert.throws( + () => select({}, { models: "gamma" }), + /Unknown benchmark model gamma; choose from alpha, beta/, + ); + assert.throws( + () => select({}, { attempts: "0" }), + /attempts input must be an integer from 1 to 10/, + ); +}); + +test("registry errors name the offending entry", () => { + for (const [overrides, message] of [ + [{ fixtures: ["agent-missing"] }, /fixture "agent-missing" was not found/], + [{ mockWorlds: ["moon"] }, /mock world "moon" must be "local" or a world with a package/], + [{ mockWorlds: ["vercel"] }, /mock world "vercel" must be "local" or a world with a package/], + [ + { + models: [ + { name: "alpha", id: "x" }, + { name: "alpha", id: "y" }, + ], + }, + /duplicate "models" entry "alpha"/, + ], + [{ judge: "" }, /"judge" must be a non-empty gateway model id/], + [{ concurrency: undefined }, /"concurrency.legs" must be an integer from 1 to 50/], + [ + { concurrency: { legs: 5, evalsPerLeg: 0 } }, + /"concurrency.evalsPerLeg" must be an integer from 1 to 16/, + ], + ]) + assert.throws(() => select(overrides), message); +}); + +test("the committed registry is valid", () => { + const committed = JSON.parse(readFileSync(new URL("../../e2e/benchmark.json", import.meta.url))); + const committedMatrix = JSON.parse( + readFileSync(new URL("../../e2e/matrix.json", import.meta.url)), + ); + const selection = selectBenchmark({ + registry: committed, + matrix: committedMatrix, + findFixture: () => ({ dir: "fixture", evalIds: ["eval"] }), + }); + assert.equal(selection.judge, committed.judge); +}); + +const catalog = + (data, ok = true) => + async () => ({ ok, status: ok ? 200 : 503, json: async () => ({ data }) }); + +test("pins each model to its catalog release and fails before running on unknown ids", async () => { + const released = Date.UTC(2026, 8, 22) / 1000; + const entries = [ + { id: "acme/alpha", released }, + { id: "acme/judge", released }, + ]; + assert.deepEqual( + await resolveModelReleases( + [{ name: "alpha", id: "acme/alpha" }], + "acme/judge", + catalog(entries), + ), + [{ name: "alpha", id: "acme/alpha", release: "2026-09-22" }], + ); + await assert.rejects( + resolveModelReleases([{ name: "beta", id: "acme/beta" }], "acme/judge", catalog(entries)), + /AI Gateway catalog has no model "acme\/beta" \(e2e\/benchmark.json model "beta"\)/, + ); + await assert.rejects( + resolveModelReleases([], "acme/other-judge", catalog(entries)), + /no judge model "acme\/other-judge"/, + ); + await assert.rejects( + resolveModelReleases( + [{ name: "alpha", id: "acme/alpha" }], + "acme/judge", + catalog([{ id: "acme/alpha" }, { id: "acme/judge" }]), + ), + /has no release date to pin/, + ); + await assert.rejects( + resolveModelReleases([], "acme/judge", catalog([], false)), + /request failed \(503\)/, + ); +}); + +test("eval digests change with the eval file or its fixture, not with other evals", () => { + const objects = { + "e2e/fixtures/agent-one/evals/a.eval.ts": "1", + "e2e/fixtures/agent-one/evals/b.eval.ts": "2", + "e2e/fixtures/agent-one/agent": "3", + "e2e/fixtures/agent-one/package.json": "4", + "e2e/fixtures/e2e-config": "5", + }; + const digest = (overrides = {}) => + evalDigests({ + sha: "sha", + fixtureDir: "e2e/fixtures/agent-one", + evalIds: ["a", "b", "b/0"], + resolve: (specs) => + new Map( + specs.flatMap((spec) => { + const id = { ...objects, ...overrides }[spec.slice("sha:".length)]; + return id === undefined ? [] : [[spec, id]]; + }), + ), + }); + const base = digest(); + assert.match(base.get("a"), /^[0-9a-f]{64}$/); + assert.equal(base.get("b/0"), base.get("b"), "array entries resolve to their file"); + const editedEval = digest({ "e2e/fixtures/agent-one/evals/b.eval.ts": "changed" }); + assert.equal(editedEval.get("a"), base.get("a")); + assert.notEqual(editedEval.get("b"), base.get("b")); + const editedAgent = digest({ "e2e/fixtures/agent-one/agent": "changed" }); + assert.notEqual(editedAgent.get("a"), base.get("a")); +}); diff --git a/scripts/eval-benchmark/publish.mjs b/scripts/eval-benchmark/publish.mjs new file mode 100644 index 0000000000..f32399b89d --- /dev/null +++ b/scripts/eval-benchmark/publish.mjs @@ -0,0 +1,184 @@ +// Publishes one benchmark run to private Vercel Blob. Every object is +// immutable; re-running with identical content is a no-op and different +// content fails. manifest.json is written last, so its presence tells ingest +// the run is complete: +// +// runs///raw/////... +// runs///records.jsonl +// runs///manifest.json +// +// Usage: node scripts/eval-benchmark/publish.mjs +// Requires EVE_BENCHMARK_BLOB_READ_WRITE_TOKEN, GITHUB_RUN_ID, GITHUB_RUN_ATTEMPT. +// TEMPORARY: EVE_BENCHMARK_BLOB_ROOT=preview/runs keeps pull request test runs +// out of the runs/ prefix that ingest reads. Remove before merge. +import { createHash } from "node:crypto"; +import { readdirSync, readFileSync } from "node:fs"; +import { join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + RECORD_SCHEMA_VERSION, + assertValidRecords, + legArtifactPath, + readLegs, +} from "./records.mjs"; + +const UPLOAD_CONCURRENCY = 8; +const BLOB_ROOTS = ["runs", "preview/runs"]; + +/** + * @typedef {{ + * put: (pathname: string, body: Buffer, contentType: string) => Promise, + * read: (pathname: string) => Promise, + * }} ImmutableStore `put` must reject with an "already exists" error rather than overwrite. + */ + +/** + * @param {{ plan: any, legs: ReturnType, recordsJsonl: string, githubRunId: string, runAttempt: number, eveVersion: string, store: ImmutableStore, root?: string }} input + */ +export async function publishRun({ + plan, + legs, + recordsJsonl, + githubRunId, + runAttempt, + eveVersion, + store, + root = "runs", +}) { + if (!BLOB_ROOTS.includes(root)) + throw new Error(`Blob root must be one of ${BLOB_ROOTS.join(", ")} (got "${root}").`); + const records = recordsJsonl + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line)); + assertValidRecords(records); + for (const record of records) { + if (record.github_run_id !== String(githubRunId) || record.run_attempt !== runAttempt) + throw new Error(`Record ${record.fixture}/${record.eval_id} belongs to another run.`); + } + + const prefix = `${root}/${githubRunId}/${runAttempt}`; + const uploads = legs.flatMap(({ leg, dir }) => + dir === undefined + ? [] + : listFiles(dir).map((path) => ({ + pathname: `${prefix}/${legArtifactPath(leg)}/${relative(dir, path).replaceAll("\\", "/")}`, + path, + })), + ); + await mapLimit(uploads, UPLOAD_CONCURRENCY, ({ pathname, path }) => + putImmutable(store, pathname, readFileSync(path), contentTypeOf(path)), + ); + + const recordsBody = Buffer.from(recordsJsonl); + await putImmutable(store, `${prefix}/records.jsonl`, recordsBody, "application/x-ndjson"); + const manifest = { + schema_version: RECORD_SCHEMA_VERSION, + github_run_id: String(githubRunId), + run_attempt: runAttempt, + sha: plan.sha, + eve_version: eveVersion, + planned_at: plan.planned_at, + registry: plan.registry, + legs: legs.map(({ leg, dir }) => ({ ...leg, uploaded: dir !== undefined })), + record_count: records.length, + records_sha256: createHash("sha256").update(recordsBody).digest("hex"), + }; + await putImmutable( + store, + `${prefix}/manifest.json`, + Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`), + "application/json", + ); + return { prefix, objects: uploads.length + 2, records: records.length }; +} + +async function putImmutable(store, pathname, body, contentType) { + try { + await store.put(pathname, body, contentType); + } catch (error) { + if (!(error instanceof Error) || !error.message.includes("already exists")) throw error; + const published = await store.read(pathname); + if (published === null) throw new Error(`${pathname} exists but could not be read back.`); + if (!published.equals(body)) { + throw new Error( + `${pathname} was already published with different content; benchmark runs are immutable. Re-run the workflow to get a new run attempt instead.`, + ); + } + } +} + +function listFiles(dir) { + return readdirSync(dir, { withFileTypes: true, recursive: true }) + .filter((entry) => entry.isFile()) + .map((entry) => join(entry.parentPath, entry.name)) + .sort(); +} + +function contentTypeOf(path) { + if (path.endsWith(".json")) return "application/json"; + if (path.endsWith(".ndjson") || path.endsWith(".jsonl")) return "application/x-ndjson"; + return "application/octet-stream"; +} + +async function mapLimit(items, limit, task) { + let next = 0; + const workers = Array.from({ length: Math.min(limit, items.length) }, async () => { + while (next < items.length) await task(items[next++]); + }); + await Promise.all(workers); +} + +/** Private, immutable Blob store backed by @vercel/blob. */ +export async function vercelBlobStore(token) { + const { get, put } = await import("@vercel/blob"); + return { + async put(pathname, body, contentType) { + await put(pathname, body, { + token, + access: "private", + addRandomSuffix: false, + allowOverwrite: false, + cacheControlMaxAge: 31_536_000, + contentType, + }); + }, + async read(pathname) { + const result = await get(pathname, { token, access: "private", useCache: false }); + return result === null ? null : Buffer.from(await new Response(result.stream).arrayBuffer()); + }, + }; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const [planPath, legsDir, recordsPath] = process.argv.slice(2); + if (recordsPath === undefined) { + console.error( + "Usage: node scripts/eval-benchmark/publish.mjs ", + ); + process.exit(1); + } + const token = process.env.EVE_BENCHMARK_BLOB_READ_WRITE_TOKEN; + if (!token) + throw new Error( + "EVE_BENCHMARK_BLOB_READ_WRITE_TOKEN is required to publish benchmark results.", + ); + const githubRunId = process.env.GITHUB_RUN_ID; + if (githubRunId === undefined) + throw new Error("GITHUB_RUN_ID is required to key the published run."); + const plan = JSON.parse(readFileSync(planPath, "utf8")); + const result = await publishRun({ + plan, + legs: readLegs(plan, legsDir), + recordsJsonl: readFileSync(recordsPath, "utf8"), + githubRunId, + runAttempt: Number(process.env.GITHUB_RUN_ATTEMPT ?? "1"), + eveVersion: JSON.parse(readFileSync("packages/eve/package.json", "utf8")).version, + store: await vercelBlobStore(token), + root: process.env.EVE_BENCHMARK_BLOB_ROOT || undefined, + }); + console.error( + `Published ${result.records} records and ${result.objects} objects to ${result.prefix}.`, + ); +} diff --git a/scripts/eval-benchmark/record.schema.json b/scripts/eval-benchmark/record.schema.json new file mode 100644 index 0000000000..bc6eb96f04 --- /dev/null +++ b/scripts/eval-benchmark/record.schema.json @@ -0,0 +1,160 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/vercel/eve/scripts/eval-benchmark/record.schema.json", + "title": "eve model benchmark record", + "description": "One attempt of one eval on one model and workflow world. The upsert key is (github_run_id, run_attempt, fixture, eval_id, model_id, world, attempt). The comparability identity is (eval_id, eval_digest, model_id, model_release, judge_model, world); sha and eve_version are the x-axis. See research/model-benchmark-tracking.md.", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "github_run_id", + "run_attempt", + "fixture", + "eval_id", + "model_id", + "model_slot", + "world", + "attempt", + "eval_digest", + "model_release", + "judge_model", + "sha", + "eve_version", + "runner_os", + "started_at", + "verdict", + "assertions", + "outcome", + "gap_reason", + "metrics_version", + "metrics", + "failures", + "bundles", + "artifact_path" + ], + "properties": { + "schema_version": { "const": 1 }, + "github_run_id": { "type": "string", "pattern": "^[0-9]+$" }, + "run_attempt": { "type": "integer", "minimum": 1 }, + "fixture": { "type": "string", "pattern": "^[a-z0-9-]+$" }, + "eval_id": { "type": "string", "minLength": 1 }, + "model_id": { + "description": "AI Gateway model id, or \"mock\" for the mock-model framework track.", + "type": "string", + "minLength": 1 + }, + "model_slot": { + "description": "Registry slot the model fills (e2e/benchmark.json name, e.g. \"openai-mid\"), or \"mock\". Slots outlive model ids: group by slot to follow a lab's tier across releases. Not part of the key or the comparability identity.", + "type": "string", + "pattern": "^[a-z0-9-]+$" + }, + "world": { "type": "string", "pattern": "^[a-z0-9-]+$" }, + "attempt": { "type": "integer", "minimum": 1 }, + "eval_digest": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "model_release": { + "description": "AI Gateway catalog release date (YYYY-MM-DD); null for mock models.", + "type": ["string", "null"], + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + }, + "judge_model": { "type": "string", "minLength": 1 }, + "sha": { "type": "string", "pattern": "^[0-9a-f]{40}$" }, + "eve_version": { + "description": "eve version reported by the serving runtime; null when the eval never reached it.", + "type": ["string", "null"] + }, + "runner_os": { "type": ["string", "null"] }, + "started_at": { "type": ["string", "null"], "format": "date-time" }, + "verdict": { + "type": ["string", "null"], + "enum": ["passed", "failed", "scored", "skipped", null] + }, + "assertions": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name", "severity", "score", "passed"], + "properties": { + "name": { "type": "string" }, + "severity": { "enum": ["gate", "soft"] }, + "score": { "type": "number", "minimum": 0, "maximum": 1 }, + "passed": { "type": "boolean" } + } + } + }, + "outcome": { "enum": ["completed", "timed_out", "skipped", "parked", "gap"] }, + "gap_reason": { + "description": "Present exactly when outcome is \"gap\"; one of GAP_REASONS in scripts/eval-metrics/gaps.mjs.", + "type": ["string", "null"], + "enum": [ + "job-missing", + "eval-missing", + "provider-unavailable", + "stream-write-failed", + "timeout-before-first-step", + "error-before-first-step", + null + ] + }, + "metrics_version": { "type": "integer", "minimum": 1 }, + "metrics": { "$ref": "#/$defs/metrics" }, + "failures": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["type", "code", "count"], + "properties": { + "type": { "enum": ["step.failed", "turn.failed", "session.failed"] }, + "code": { "type": "string" }, + "count": { "type": "integer", "minimum": 1 } + } + } + }, + "bundles": { + "description": "Fixture metric bundles keyed by bundle name; metric values are namespaced . by consumers.", + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "required": ["version", "metrics"], + "properties": { + "version": { "type": "integer", "minimum": 1 }, + "metrics": { "$ref": "#/$defs/metrics" } + } + } + }, + "artifact_path": { + "description": "Path of this leg's raw artifact tree, relative to runs///; null when the leg uploaded nothing.", + "type": ["string", "null"] + } + }, + "$defs": { + "metrics": { + "type": "object", + "additionalProperties": { "$ref": "#/$defs/measurement" } + }, + "measurement": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["status", "value"], + "properties": { + "status": { "const": "measured" }, + "value": { "type": "number" } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["status", "reason"], + "properties": { + "status": { "enum": ["unavailable", "not-applicable"] }, + "reason": { "type": "string", "minLength": 1 } + } + } + ] + } + } +} diff --git a/scripts/eval-benchmark/records.mjs b/scripts/eval-benchmark/records.mjs new file mode 100644 index 0000000000..73420f69fb --- /dev/null +++ b/scripts/eval-benchmark/records.mjs @@ -0,0 +1,230 @@ +// Derives records.jsonl from the run legs' uploaded artifacts. Each leg +// artifact (downloaded to /benchmark-leg-/) holds: +// +// meta.json { leg_id, sha, runner_os } written by the run job +// expected.json `eve eval --list --json` under the leg's tag filters +// run/ the leg's single `.eve/evals/` tree +// +// Planned legs that uploaded nothing, and expected evals with no result, +// become `gap` records so coverage stays honest. A live leg's eval whose model +// steps all ran on another model (fixtures script some delegation evals with +// eve's mock models) says nothing about the leg's model, so it is excluded; +// the mock track still measures it. +// +// Usage: node scripts/eval-benchmark/records.mjs +import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { classifyOutcome } from "../eval-metrics/gaps.mjs"; +import { + METRICS_VERSION, + deriveAttemptMetrics, + listEvalArtifacts, +} from "../eval-metrics/standard.mjs"; +import { evalDigests } from "./digest.mjs"; +import { validate } from "./schema.mjs"; + +export const RECORD_SCHEMA_VERSION = 1; +export const LEG_ARTIFACT_PREFIX = "benchmark-leg-"; + +/** Map downloaded leg directories onto planned legs, rejecting anything unplanned. */ +export function readLegs(plan, legsDir) { + const planned = new Map(plan.legs.map((leg) => [leg.leg_id, leg])); + const found = new Map(); + const entries = existsSync(legsDir) ? readdirSync(legsDir, { withFileTypes: true }) : []; + for (const entry of entries) { + if (!entry.isDirectory() || !entry.name.startsWith(LEG_ARTIFACT_PREFIX)) + throw new Error( + `Unexpected entry "${entry.name}" in ${legsDir}; expected ${LEG_ARTIFACT_PREFIX} directories.`, + ); + const dir = join(legsDir, entry.name); + const metaPath = join(dir, "meta.json"); + if (!existsSync(metaPath)) throw new Error(`${dir} has no meta.json.`); + const meta = JSON.parse(readFileSync(metaPath, "utf8")); + if (`${LEG_ARTIFACT_PREFIX}${meta.leg_id}` !== entry.name) + throw new Error(`${metaPath}: leg_id "${meta.leg_id}" does not match its artifact name.`); + if (!planned.has(meta.leg_id)) + throw new Error(`${metaPath}: leg "${meta.leg_id}" was not planned.`); + if (meta.sha !== plan.sha) + throw new Error(`${metaPath}: ran at ${meta.sha}, but the plan is for ${plan.sha}.`); + const expectedPath = join(dir, "expected.json"); + found.set(meta.leg_id, { + dir, + meta, + expected: existsSync(expectedPath) + ? JSON.parse(readFileSync(expectedPath, "utf8")).map((entry) => entry.id) + : undefined, + }); + } + return plan.legs.map((leg) => ({ leg, ...found.get(leg.leg_id) })); +} + +/** Path of a leg's raw tree, relative to the run's Blob prefix. */ +export function legArtifactPath(leg) { + return `raw/${leg.fixture}/${leg.model_name}/${leg.world}/${leg.attempt}`; +} + +/** + * @param {{ + * plan: any, + * legs: ReturnType, + * githubRunId: string, + * runAttempt: number, + * digestFor: (fixture: { name: string, dir: string }, evalIds: string[]) => Map, + * }} input + */ +export function buildRecords({ plan, legs, githubRunId, runAttempt, digestFor }) { + const excluded = []; + const fixtures = new Map(plan.registry.fixtures.map((fixture) => [fixture.name, fixture])); + const releases = new Map(plan.registry.models.map((model) => [model.id, model.release])); + const attempts = []; + for (const { leg, dir, meta, expected } of legs) { + const fixture = fixtures.get(leg.fixture); + const artifacts = dir === undefined ? [] : listEvalArtifacts(join(dir, "run")); + const results = new Map( + artifacts.map((path) => { + const artifact = JSON.parse(readFileSync(path, "utf8")); + return [artifact.id, artifact]; + }), + ); + const missingReason = dir === undefined ? "job-missing" : "eval-missing"; + const expectedIds = expected ?? fixture.eval_ids; + for (const evalId of new Set([...expectedIds, ...results.keys()])) { + const artifact = results.get(evalId); + if (leg.kind === "live" && artifact !== undefined && !ranOnModel(artifact, leg.model_id)) { + excluded.push({ leg_id: leg.leg_id, eval_id: evalId }); + continue; + } + attempts.push({ + leg, + meta, + evalId, + artifact, + missingReason, + uploaded: dir !== undefined, + }); + } + } + + const digests = new Map(); + for (const fixture of plan.registry.fixtures) { + const ids = [ + ...new Set(attempts.filter((a) => a.leg.fixture === fixture.name).map((a) => a.evalId)), + ]; + for (const [id, digest] of digestFor(fixture, ids)) + digests.set(`${fixture.name}\0${id}`, digest); + } + + const records = attempts.map(({ leg, meta, evalId, artifact, missingReason, uploaded }) => { + const base = { + schema_version: RECORD_SCHEMA_VERSION, + github_run_id: String(githubRunId), + run_attempt: runAttempt, + fixture: leg.fixture, + eval_id: evalId, + model_id: leg.model_id, + model_slot: leg.model_name, + world: leg.world, + attempt: leg.attempt, + eval_digest: digests.get(`${leg.fixture}\0${evalId}`), + model_release: leg.kind === "mock" ? null : (releases.get(leg.model_id) ?? null), + judge_model: plan.registry.judge, + sha: plan.sha, + runner_os: meta?.runner_os ?? null, + artifact_path: uploaded ? legArtifactPath(leg) : null, + }; + if (artifact === undefined) { + return { + ...base, + eve_version: null, + started_at: null, + verdict: null, + assertions: [], + outcome: "gap", + gap_reason: missingReason, + metrics_version: METRICS_VERSION, + metrics: {}, + failures: [], + bundles: {}, + }; + } + const { outcome, gap_reason } = classifyOutcome(artifact); + const { metrics_version, metrics, failures } = deriveAttemptMetrics(artifact); + return { + ...base, + eve_version: artifact.result?.runtimeIdentity?.eveVersion ?? null, + started_at: artifact.startedAt ?? null, + verdict: artifact.verdict ?? null, + assertions: (artifact.assertions ?? []).map(({ name, severity, score, passed }) => ({ + name, + severity, + score, + passed, + })), + outcome, + gap_reason: gap_reason ?? null, + metrics_version, + metrics, + failures, + bundles: {}, + }; + }); + return { records, excluded }; +} + +/** + * False only when the eval made model calls and none used `modelId`. An eval + * that never reached a model step stays, so it can surface as a gap. + */ +function ranOnModel(artifact, modelId) { + const models = (artifact.result?.sessions ?? []).flatMap((session) => + (session.events ?? []).filter((e) => e.type === "step.started").map((e) => e.data?.modelId), + ); + return models.length === 0 || models.includes(modelId); +} + +/** Throw with every schema violation, naming the offending record. */ +export function assertValidRecords(records) { + const problems = records.flatMap((record, index) => + validate(record).map( + (error) => + `record ${index} (${record?.fixture}/${record?.eval_id}, ${record?.model_id}): ${error}`, + ), + ); + if (problems.length > 0) + throw new Error(`Invalid benchmark records:\n${problems.slice(0, 50).join("\n")}`); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const [planPath, legsDir, outPath] = process.argv.slice(2); + if (outPath === undefined) { + console.error( + "Usage: node scripts/eval-benchmark/records.mjs ", + ); + process.exit(1); + } + const githubRunId = process.env.GITHUB_RUN_ID; + const runAttempt = Number(process.env.GITHUB_RUN_ATTEMPT ?? "1"); + if (githubRunId === undefined) + throw new Error("GITHUB_RUN_ID is required to key benchmark records."); + const plan = JSON.parse(readFileSync(planPath, "utf8")); + const { records, excluded } = buildRecords({ + plan, + legs: readLegs(plan, legsDir), + githubRunId, + runAttempt, + digestFor: (fixture, evalIds) => + evalDigests({ sha: plan.sha, fixtureDir: fixture.dir, evalIds }), + }); + assertValidRecords(records); + writeFileSync(outPath, records.map((record) => `${JSON.stringify(record)}\n`).join("")); + const gaps = records.filter((record) => record.outcome === "gap").length; + console.error(`Wrote ${records.length} records (${gaps} gaps) to ${outPath}.`); + if (excluded.length > 0) { + console.error( + `Excluded ${excluded.length} live-leg evals whose model steps never used the leg's model:`, + ); + for (const { leg_id, eval_id } of excluded) console.error(` ${leg_id} ${eval_id}`); + } +} diff --git a/scripts/eval-benchmark/records.test.mjs b/scripts/eval-benchmark/records.test.mjs new file mode 100644 index 0000000000..e4a73f0a77 --- /dev/null +++ b/scripts/eval-benchmark/records.test.mjs @@ -0,0 +1,238 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { publishRun } from "./publish.mjs"; +import { assertValidRecords, buildRecords, readLegs } from "./records.mjs"; +import { validate } from "./schema.mjs"; + +const SHA = "a".repeat(40); +const fixturesDir = new URL("../eval-metrics/fixtures/", import.meta.url); +const plan = { + schema_version: 1, + sha: SHA, + planned_at: "2026-01-01T00:00:00.000Z", + registry: { + judge: "acme/judge", + attempts: 1, + models: [{ name: "alpha", id: "acme/alpha", release: "2026-09-22" }], + mock_worlds: ["local"], + fixtures: [ + { + name: "agent-tools", + dir: "e2e/fixtures/agent-tools", + eval_ids: ["static-tools/ends-turn-function", "static-tools/no-reply"], + }, + ], + }, + legs: [ + { + leg_id: "live__agent-tools__alpha__local__1", + kind: "live", + fixture: "agent-tools", + dir: "e2e/fixtures/agent-tools", + model_name: "alpha", + model_id: "acme/alpha", + world: "local", + world_package: "", + attempt: 1, + }, + { + leg_id: "mock__agent-tools__mock__local__1", + kind: "mock", + fixture: "agent-tools", + dir: "e2e/fixtures/agent-tools", + model_name: "mock", + model_id: "mock", + world: "local", + world_package: "", + attempt: 1, + }, + ], +}; + +/** One uploaded live leg whose run produced one of its two expected evals; the mock leg is missing. */ +function legsDirectory(t, meta = {}) { + const root = mkdtempSync(join(tmpdir(), "eve-benchmark-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const leg = join(root, "benchmark-leg-live__agent-tools__alpha__local__1"); + mkdirSync(join(leg, "run", "evals", "static-tools"), { recursive: true }); + writeFileSync( + join(leg, "meta.json"), + JSON.stringify({ + leg_id: "live__agent-tools__alpha__local__1", + sha: SHA, + runner_os: "Linux", + ...meta, + }), + ); + writeEval(leg, "static-tools/ends-turn-function", "acme/alpha"); + writeFileSync(join(leg, "run", "summary.json"), "{}"); + return root; +} + +/** Copy a captured mock-model eval into a leg, as if its steps ran on `modelId`. */ +function writeEval(leg, evalId, modelId) { + const artifact = JSON.parse( + readFileSync(new URL("agent-tools-ends-turn-function.json", fixturesDir), "utf8"), + ); + artifact.id = evalId; + for (const session of artifact.result.sessions) + for (const event of session.events) + if (event.type === "step.started") event.data.modelId = modelId; + writeFileSync(join(leg, "run", "evals", `${evalId}.json`), JSON.stringify(artifact)); +} + +const build = (legsDir) => buildWithExclusions(legsDir).records; +const buildWithExclusions = (legsDir) => + buildRecords({ + plan, + legs: readLegs(plan, legsDir), + githubRunId: "123", + runAttempt: 1, + digestFor: (_fixture, ids) => new Map(ids.map((id) => [id, "f".repeat(64)])), + }); + +test("builds schema-valid records and turns missing legs and evals into gaps", (t) => { + const records = build(legsDirectory(t)); + assertValidRecords(records); + const summary = records.map((r) => [ + r.model_id, + r.eval_id, + r.outcome, + r.gap_reason, + r.artifact_path, + ]); + assert.deepEqual(summary, [ + [ + "acme/alpha", + "static-tools/ends-turn-function", + "completed", + null, + "raw/agent-tools/alpha/local/1", + ], + ["acme/alpha", "static-tools/no-reply", "gap", "eval-missing", "raw/agent-tools/alpha/local/1"], + ["mock", "static-tools/ends-turn-function", "gap", "job-missing", null], + ["mock", "static-tools/no-reply", "gap", "job-missing", null], + ]); + const [completed] = records; + assert.equal(completed.eve_version, "0.71.2"); + assert.equal(completed.model_release, "2026-09-22"); + assert.equal(completed.runner_os, "Linux"); + assert.equal(completed.metrics["counts.turns"].value, 2); + assert.equal(records[2].model_release, null); + assert.deepEqual( + records.map((r) => r.model_slot), + ["alpha", "alpha", "mock", "mock"], + ); +}); + +test("excludes live-leg evals whose model steps all ran on another model", (t) => { + const legsDir = legsDirectory(t); + const leg = join(legsDir, "benchmark-leg-live__agent-tools__alpha__local__1"); + // Some fixtures script delegation evals with eve's mock models even on live legs. + writeEval(leg, "static-tools/no-reply", "eve-mock/notebook-parent"); + const { records, excluded } = buildWithExclusions(legsDir); + assert.deepEqual(excluded, [ + { leg_id: "live__agent-tools__alpha__local__1", eval_id: "static-tools/no-reply" }, + ]); + const live = records.filter((r) => r.model_id === "acme/alpha"); + assert.deepEqual( + live.map((r) => [r.eval_id, r.outcome]), + [["static-tools/ends-turn-function", "completed"]], + "an excluded eval is neither a record nor a gap", + ); +}); + +test("rejects artifacts that were not planned or ran at another commit", (t) => { + assert.throws( + () => readLegs(plan, legsDirectory(t, { sha: "b".repeat(40) })), + /ran at b+, but the plan is for a+/, + ); + const unplanned = { ...plan, legs: plan.legs.slice(1) }; + assert.throws(() => readLegs(unplanned, legsDirectory(t)), /was not planned/); +}); + +test("the schema rejects records that break the contract", (t) => { + const [record] = build(legsDirectory(t)); + assert.deepEqual(validate(record), []); + const broken = { + ...record, + outcome: "crashed", + extra: true, + metrics: { "latency.turn_ms": { status: "measured" } }, + }; + delete broken.sha; + assert.deepEqual(validate(broken), [ + '$: missing required "sha"', + '$.outcome: "crashed" is not one of ["completed","timed_out","skipped","parked","gap"]', + "$.metrics.latency.turn_ms: must match exactly one schema option (matched 0)", + '$: unexpected property "extra"', + ]); +}); + +function memoryStore() { + const objects = new Map(); + const writes = []; + return { + objects, + writes, + async put(pathname, body) { + if (objects.has(pathname)) + throw new Error(`Vercel Blob: This blob already exists (${pathname}).`); + objects.set(pathname, Buffer.from(body)); + writes.push(pathname); + }, + async read(pathname) { + return objects.get(pathname) ?? null; + }, + }; +} + +test("publishes raw trees and records before the manifest, idempotently and immutably", async (t) => { + const legsDir = legsDirectory(t); + const legs = readLegs(plan, legsDir); + const recordsJsonl = build(legsDir) + .map((record) => `${JSON.stringify(record)}\n`) + .join(""); + const store = memoryStore(); + const input = { + plan, + legs, + recordsJsonl, + githubRunId: "123", + runAttempt: 1, + eveVersion: "0.71.2", + store, + }; + + const result = await publishRun(input); + assert.equal(result.prefix, "runs/123/1"); + assert.equal(store.writes.at(-1), "runs/123/1/manifest.json"); + assert( + store.objects.has( + "runs/123/1/raw/agent-tools/alpha/local/1/run/evals/static-tools/ends-turn-function.json", + ), + ); + assert(store.objects.has("runs/123/1/raw/agent-tools/alpha/local/1/meta.json")); + const manifest = JSON.parse(store.objects.get("runs/123/1/manifest.json")); + assert.equal(manifest.record_count, 4); + assert.deepEqual( + manifest.legs.map((leg) => leg.uploaded), + [true, false], + ); + + const writes = store.writes.length; + await publishRun(input); + assert.equal(store.writes.length, writes, "identical re-run is a no-op"); + + await assert.rejects( + publishRun({ ...input, recordsJsonl: recordsJsonl.split("\n").slice(1).join("\n") }), + /records.jsonl was already published with different content/, + ); + + const preview = await publishRun({ ...input, store: memoryStore(), root: "preview/runs" }); + assert.equal(preview.prefix, "preview/runs/123/1"); + await assert.rejects(publishRun({ ...input, root: "elsewhere" }), /Blob root must be one of/); +}); diff --git a/scripts/eval-benchmark/schema.mjs b/scripts/eval-benchmark/schema.mjs new file mode 100644 index 0000000000..810be72f14 --- /dev/null +++ b/scripts/eval-benchmark/schema.mjs @@ -0,0 +1,118 @@ +// Minimal JSON Schema validator for record.schema.json. It covers exactly the +// keywords that schema uses, so the contract needs no new dependency. +import { readFileSync } from "node:fs"; + +export const RECORD_SCHEMA = JSON.parse( + readFileSync(new URL("./record.schema.json", import.meta.url), "utf8"), +); + +const SUPPORTED = new Set([ + "$schema", + "$id", + "$defs", + "$ref", + "title", + "description", + "format", + "type", + "const", + "enum", + "required", + "properties", + "additionalProperties", + "items", + "pattern", + "minLength", + "minimum", + "maximum", + "oneOf", +]); + +/** @returns {string[]} human-readable errors; empty when valid */ +export function validate(value, schema = RECORD_SCHEMA, root = schema, path = "$") { + for (const keyword of Object.keys(schema)) { + if (!SUPPORTED.has(keyword)) + throw new Error(`Unsupported schema keyword "${keyword}" at ${path}.`); + } + if (schema.$ref !== undefined) { + const target = schema.$ref + .replace(/^#\//, "") + .split("/") + .reduce((node, key) => node?.[key], root); + if (target === undefined) throw new Error(`Unresolved schema reference ${schema.$ref}.`); + return validate(value, target, root, path); + } + const errors = []; + if (schema.type !== undefined) { + const types = [schema.type].flat(); + if (!types.some((type) => hasType(value, type))) + return [`${path}: expected ${types.join(" or ")}, got ${describe(value)}`]; + } + if ("const" in schema && value !== schema.const) + errors.push(`${path}: expected ${JSON.stringify(schema.const)}`); + if (schema.enum !== undefined && !schema.enum.includes(value)) + errors.push(`${path}: ${JSON.stringify(value)} is not one of ${JSON.stringify(schema.enum)}`); + if (typeof value === "string") { + if (schema.pattern !== undefined && !new RegExp(schema.pattern, "u").test(value)) + errors.push(`${path}: does not match ${schema.pattern}`); + if (schema.minLength !== undefined && value.length < schema.minLength) + errors.push(`${path}: shorter than ${schema.minLength}`); + } + if (typeof value === "number") { + if (schema.minimum !== undefined && value < schema.minimum) + errors.push(`${path}: below ${schema.minimum}`); + if (schema.maximum !== undefined && value > schema.maximum) + errors.push(`${path}: above ${schema.maximum}`); + } + if (Array.isArray(value) && schema.items !== undefined) { + value.forEach((item, index) => + errors.push(...validate(item, schema.items, root, `${path}[${index}]`)), + ); + } + if (isObject(value)) { + for (const key of schema.required ?? []) { + if (!(key in value)) errors.push(`${path}: missing required "${key}"`); + } + for (const [key, item] of Object.entries(value)) { + const property = schema.properties?.[key]; + if (property !== undefined) errors.push(...validate(item, property, root, `${path}.${key}`)); + else if (schema.additionalProperties === false) + errors.push(`${path}: unexpected property "${key}"`); + else if (isObject(schema.additionalProperties)) + errors.push(...validate(item, schema.additionalProperties, root, `${path}.${key}`)); + } + } + if (schema.oneOf !== undefined) { + const matches = schema.oneOf.filter( + (option) => validate(value, option, root, path).length === 0, + ); + if (matches.length !== 1) + errors.push(`${path}: must match exactly one schema option (matched ${matches.length})`); + } + return errors; +} + +function hasType(value, type) { + switch (type) { + case "null": + return value === null; + case "array": + return Array.isArray(value); + case "object": + return isObject(value); + case "integer": + return Number.isInteger(value); + case "number": + return typeof value === "number" && Number.isFinite(value); + default: + return typeof value === type; + } +} + +function isObject(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function describe(value) { + return value === null ? "null" : Array.isArray(value) ? "array" : typeof value; +} diff --git a/scripts/eval-metrics/fixtures/agent-subagents-continuation-local.json b/scripts/eval-metrics/fixtures/agent-subagents-continuation-local.json new file mode 100644 index 0000000000..4fc722e12e --- /dev/null +++ b/scripts/eval-metrics/fixtures/agent-subagents-continuation-local.json @@ -0,0 +1,1264 @@ +{ + "id": "agent-continuation-local", + "verdict": "passed", + "assertions": [ + { + "name": "messageIncludes(NOTEBOOK-REPLY NOTEBOOK-SAVED)", + "severity": "gate", + "score": 1, + "passed": true + }, + { "name": "calledTool(task_cancel)", "severity": "gate", "score": 1, "passed": true }, + { "name": "event(task.settled)", "severity": "gate", "score": 1, "passed": true }, + { "name": "event(turn.cancelled)", "severity": "gate", "score": 1, "passed": true }, + { "name": "event(task.settled)", "severity": "gate", "score": 1, "passed": true }, + { + "name": "messageIncludes(NOTEBOOK-REPLY NOTEBOOK-NAME=Harbor Lumen 4482)", + "severity": "gate", + "score": 1, + "passed": true + }, + { "name": "event(agent.started)", "severity": "gate", "score": 1, "passed": true }, + { + "name": "eventsSatisfy(each turn's call reaches the one task the first call started)", + "severity": "gate", + "score": 1, + "passed": true + }, + { "name": "noFailedActions", "severity": "gate", "score": 1, "passed": true } + ], + "startedAt": "2026-10-05T23:37:05.534Z", + "completedAt": "2026-10-05T23:37:08.653Z", + "result": { + "sessionId": "wrun_01M476V5RJPATF4WMMJD3PAX09", + "status": "waiting", + "runtimeIdentity": { + "agentId": "agent-subagents", + "agentName": "agent-subagents", + "eveVersion": "0.71.2" + }, + "sessions": [ + { + "sessionId": "wrun_01M476V5RJPATF4WMMJD3PAX09", + "primary": true, + "events": [ + { + "data": { + "runtime": { + "agentId": "agent-subagents", + "agentName": "agent-subagents", + "eveVersion": "0.71.2" + } + }, + "type": "session.started", + "meta": { + "at": "2026-10-05T23:37:05.857Z", + "id": "evt_01M476V621XG7SCYF1G0HF57SR", + "deliveryIds": ["52cd0222-f807-46ed-8cc1-f0adcd3f93fb"] + } + }, + { + "data": { "sequence": 0, "turnId": "turn_0" }, + "type": "turn.started", + "meta": { + "at": "2026-10-05T23:37:05.858Z", + "id": "evt_01M476V6228BYTWHV7MKWND209", + "deliveryIds": ["52cd0222-f807-46ed-8cc1-f0adcd3f93fb"] + } + }, + { + "data": { + "message": "NOTEBOOK-REMEMBER notebook-keeper Alice shares the notebo...", + "sequence": 0, + "turnId": "turn_0" + }, + "type": "message.received", + "meta": { + "at": "2026-10-05T23:37:05.859Z", + "id": "evt_01M476V6233AEN19P97Z73SW8X", + "deliveryIds": ["52cd0222-f807-46ed-8cc1-f0adcd3f93fb"] + } + }, + { + "data": { + "modelId": "eve-mock/notebook-parent", + "sequence": 0, + "stepIndex": 0, + "turnId": "turn_0" + }, + "type": "step.started", + "meta": { + "at": "2026-10-05T23:37:05.859Z", + "id": "evt_01M476V6233AEN19P97Z73SW8Y", + "deliveryIds": ["52cd0222-f807-46ed-8cc1-f0adcd3f93fb"] + } + }, + { + "data": { + "actions": [ + { + "callId": "notebook-remember", + "input": { + "message": "NOTEBOOK-ENTRY Alice named the tide station notebook Harb..." + }, + "kind": "tool-call", + "toolName": "notebook-keeper" + } + ], + "sequence": 0, + "stepIndex": 0, + "turnId": "turn_0" + }, + "type": "actions.requested", + "meta": { + "at": "2026-10-05T23:37:05.868Z", + "id": "evt_01M476V62C7GS0XB8YSMBRD14H", + "deliveryIds": ["52cd0222-f807-46ed-8cc1-f0adcd3f93fb"] + } + }, + { + "data": { + "finishReason": "tool-calls", + "sequence": 0, + "stepIndex": 0, + "turnId": "turn_0", + "usage": { + "inputTokens": 332, + "outputTokens": 27, + "cacheReadTokens": 0, + "cacheWriteTokens": 0 + } + }, + "type": "step.completed", + "meta": { + "at": "2026-10-05T23:37:05.871Z", + "id": "evt_01M476V62FPZP28T46PNM7S065", + "deliveryIds": ["52cd0222-f807-46ed-8cc1-f0adcd3f93fb"] + } + }, + { + "data": { + "callId": "notebook-remember", + "kind": "agent", + "name": "notebook-keeper", + "taskId": "notebook-keeper-eqtna6", + "turnId": "turn_0" + }, + "type": "task.started", + "meta": { + "at": "2026-10-05T23:37:05.906Z", + "id": "evt_01M476V63J65W3D9RW1NNBY17K", + "deliveryIds": ["52cd0222-f807-46ed-8cc1-f0adcd3f93fb"] + } + }, + { + "data": { + "result": { + "callId": "notebook-remember", + "kind": "tool-result", + "output": "Started task notebook-keeper-eqtna6. Its result will arri...", + "toolName": "notebook-keeper" + }, + "sequence": 0, + "status": "completed", + "stepIndex": 0, + "turnId": "turn_0" + }, + "type": "action.result", + "meta": { + "at": "2026-10-05T23:37:05.921Z", + "id": "evt_01M476V641X0H8BK1KC4JV92AZ", + "deliveryIds": ["52cd0222-f807-46ed-8cc1-f0adcd3f93fb"] + } + }, + { + "data": { + "modelId": "eve-mock/notebook-parent", + "sequence": 0, + "stepIndex": 1, + "turnId": "turn_0" + }, + "type": "step.started", + "meta": { + "at": "2026-10-05T23:37:05.922Z", + "id": "evt_01M476V642AZR2058TEEM9X5EE", + "deliveryIds": ["52cd0222-f807-46ed-8cc1-f0adcd3f93fb"] + } + }, + { + "data": { + "actions": [ + { + "callId": "notebook-remember-wait", + "input": {}, + "kind": "tool-call", + "toolName": "task_wait" + } + ], + "sequence": 0, + "stepIndex": 1, + "turnId": "turn_0" + }, + "type": "actions.requested", + "meta": { + "at": "2026-10-05T23:37:05.925Z", + "id": "evt_01M476V645XV19K4368X5VK1B6", + "deliveryIds": ["52cd0222-f807-46ed-8cc1-f0adcd3f93fb"] + } + }, + { + "data": { + "finishReason": "tool-calls", + "sequence": 0, + "stepIndex": 1, + "turnId": "turn_0", + "usage": { + "inputTokens": 402, + "outputTokens": 1, + "cacheReadTokens": 0, + "cacheWriteTokens": 0 + } + }, + "type": "step.completed", + "meta": { + "at": "2026-10-05T23:37:05.926Z", + "id": "evt_01M476V646ZCXJ0YY0CE8DSFT3", + "deliveryIds": ["52cd0222-f807-46ed-8cc1-f0adcd3f93fb"] + } + }, + { + "data": { + "on": "tasks", + "sequence": 0, + "turnId": "turn_0", + "usage": { + "cacheReadTokens": 0, + "cacheWriteTokens": 0, + "inputTokens": 734, + "outputTokens": 28 + } + }, + "type": "turn.waiting", + "meta": { + "at": "2026-10-05T23:37:05.953Z", + "id": "evt_01M476V6513WBWDSX7YNC2RDDV", + "deliveryIds": ["52cd0222-f807-46ed-8cc1-f0adcd3f93fb"] + } + }, + { + "data": { + "callId": "notebook-remember", + "turnId": "turn_0", + "name": "notebook-keeper", + "sessionId": "wrun_01M476V658DK2JBTYGB4XWHDZ0", + "taskId": "notebook-keeper-eqtna6" + }, + "type": "agent.started", + "meta": { + "at": "2026-10-05T23:37:06.057Z", + "id": "evt_01M476V6898SRG54ZSBFDFWJZH", + "deliveryIds": ["52cd0222-f807-46ed-8cc1-f0adcd3f93fb"] + } + }, + { + "data": { + "callId": "notebook-remember", + "kind": "agent", + "name": "notebook-keeper", + "status": "completed", + "taskId": "notebook-keeper-eqtna6", + "turnId": "turn_0", + "output": "NOTEBOOK-SAVED" + }, + "type": "task.settled", + "meta": { + "at": "2026-10-05T23:37:06.114Z", + "id": "evt_01M476V6A2Z1SG7X142798JF05", + "deliveryIds": ["52cd0222-f807-46ed-8cc1-f0adcd3f93fb"] + } + }, + { + "data": { + "result": { + "callId": "notebook-remember-wait", + "kind": "tool-result", + "output": "notebook-keeper-eqtna6 completed; its result follows.", + "toolName": "task_wait" + }, + "sequence": 0, + "status": "completed", + "stepIndex": 1, + "turnId": "turn_0" + }, + "type": "action.result", + "meta": { + "at": "2026-10-05T23:37:06.127Z", + "id": "evt_01M476V6AF87ZGQAP6CT77QW5G", + "deliveryIds": ["52cd0222-f807-46ed-8cc1-f0adcd3f93fb"] + } + }, + { + "data": { + "modelId": "eve-mock/notebook-parent", + "sequence": 0, + "stepIndex": 2, + "turnId": "turn_0" + }, + "type": "step.started", + "meta": { + "at": "2026-10-05T23:37:06.127Z", + "id": "evt_01M476V6AF87ZGQAP6CT77QW5H", + "deliveryIds": ["52cd0222-f807-46ed-8cc1-f0adcd3f93fb"] + } + }, + { + "data": { + "messageDelta": "NOTEBOOK-REPLY NOTEBOOK-SAVED", + "sequence": 0, + "stepIndex": 2, + "turnId": "turn_0" + }, + "type": "message.appended", + "meta": { + "at": "2026-10-05T23:37:06.130Z", + "id": "evt_01M476V6AJFFZPR5418NZFPRTZ", + "deliveryIds": ["52cd0222-f807-46ed-8cc1-f0adcd3f93fb"] + } + }, + { + "data": { + "finishReason": "stop", + "message": "NOTEBOOK-REPLY NOTEBOOK-SAVED", + "sequence": 0, + "stepIndex": 2, + "turnId": "turn_0" + }, + "type": "message.completed", + "meta": { + "at": "2026-10-05T23:37:06.130Z", + "id": "evt_01M476V6AJFFZPR5418NZFPRV0", + "deliveryIds": ["52cd0222-f807-46ed-8cc1-f0adcd3f93fb"] + } + }, + { + "data": { + "finishReason": "stop", + "sequence": 0, + "stepIndex": 2, + "turnId": "turn_0", + "usage": { + "inputTokens": 468, + "outputTokens": 8, + "cacheReadTokens": 0, + "cacheWriteTokens": 0 + } + }, + "type": "step.completed", + "meta": { + "at": "2026-10-05T23:37:06.130Z", + "id": "evt_01M476V6AJFFZPR5418NZFPRV1", + "deliveryIds": ["52cd0222-f807-46ed-8cc1-f0adcd3f93fb"] + } + }, + { + "data": { "sequence": 0, "turnId": "turn_0" }, + "type": "turn.completed", + "meta": { + "at": "2026-10-05T23:37:06.133Z", + "id": "evt_01M476V6AN6BYGDG7F56X57112", + "deliveryIds": ["52cd0222-f807-46ed-8cc1-f0adcd3f93fb"] + } + }, + { + "data": { + "continuationToken": "wrun_01M476V5RJPATF4WMMJD3PAX09", + "usage": { + "cacheReadTokens": 0, + "cacheWriteTokens": 0, + "inputTokens": 1332, + "outputTokens": 40 + }, + "wait": "next-user-message" + }, + "type": "session.waiting", + "meta": { + "at": "2026-10-05T23:37:06.133Z", + "id": "evt_01M476V6AN6BYGDG7F56X57113", + "deliveryIds": ["52cd0222-f807-46ed-8cc1-f0adcd3f93fb"] + } + }, + { + "data": { "sequence": 1, "turnId": "turn_1" }, + "type": "turn.started", + "meta": { + "at": "2026-10-05T23:37:06.185Z", + "id": "evt_01M476V6C9MRXR1GD56EV73ZQF", + "deliveryIds": ["524d7c94-9c42-4d20-9d90-8f32a965ffa2"] + } + }, + { + "data": { + "message": "NOTEBOOK-REVIEW notebook-keeper Alice asks for a review, ...", + "sequence": 1, + "turnId": "turn_1" + }, + "type": "message.received", + "meta": { + "at": "2026-10-05T23:37:06.185Z", + "id": "evt_01M476V6C9MRXR1GD56EV73ZQG", + "deliveryIds": ["524d7c94-9c42-4d20-9d90-8f32a965ffa2"] + } + }, + { + "data": { + "modelId": "eve-mock/notebook-parent", + "sequence": 1, + "stepIndex": 0, + "turnId": "turn_1" + }, + "type": "step.started", + "meta": { + "at": "2026-10-05T23:37:06.185Z", + "id": "evt_01M476V6C9MRXR1GD56EV73ZQH", + "deliveryIds": ["524d7c94-9c42-4d20-9d90-8f32a965ffa2"] + } + }, + { + "data": { + "actions": [ + { + "callId": "notebook-review", + "input": { + "message": "NOTEBOOK-ENTRY Please review the notebook pages before Al...", + "taskId": "notebook-keeper-eqtna6" + }, + "kind": "tool-call", + "toolName": "notebook-keeper" + } + ], + "sequence": 1, + "stepIndex": 0, + "turnId": "turn_1" + }, + "type": "actions.requested", + "meta": { + "at": "2026-10-05T23:37:06.188Z", + "id": "evt_01M476V6CC7A5R3Q13GNTS1WV3", + "deliveryIds": ["524d7c94-9c42-4d20-9d90-8f32a965ffa2"] + } + }, + { + "data": { + "finishReason": "tool-calls", + "sequence": 1, + "stepIndex": 0, + "turnId": "turn_1", + "usage": { + "inputTokens": 496, + "outputTokens": 32, + "cacheReadTokens": 0, + "cacheWriteTokens": 0 + } + }, + "type": "step.completed", + "meta": { + "at": "2026-10-05T23:37:06.188Z", + "id": "evt_01M476V6CC7A5R3Q13GNTS1WV4", + "deliveryIds": ["524d7c94-9c42-4d20-9d90-8f32a965ffa2"] + } + }, + { + "data": { + "callId": "notebook-review", + "kind": "agent", + "name": "notebook-keeper", + "taskId": "notebook-keeper-eqtna6", + "turnId": "turn_1" + }, + "type": "task.started", + "meta": { + "at": "2026-10-05T23:37:06.215Z", + "id": "evt_01M476V6D7X4DPT801AKTD751Q", + "deliveryIds": ["524d7c94-9c42-4d20-9d90-8f32a965ffa2"] + } + }, + { + "data": { + "result": { + "callId": "notebook-review", + "kind": "tool-result", + "output": "Sent to task notebook-keeper-eqtna6. Its reply will arriv...", + "toolName": "notebook-keeper" + }, + "sequence": 1, + "status": "completed", + "stepIndex": 0, + "turnId": "turn_1" + }, + "type": "action.result", + "meta": { + "at": "2026-10-05T23:37:06.232Z", + "id": "evt_01M476V6DRHF4FYDKZ1NVPXHE3", + "deliveryIds": ["524d7c94-9c42-4d20-9d90-8f32a965ffa2"] + } + }, + { + "data": { + "modelId": "eve-mock/notebook-parent", + "sequence": 1, + "stepIndex": 1, + "turnId": "turn_1" + }, + "type": "step.started", + "meta": { + "at": "2026-10-05T23:37:06.233Z", + "id": "evt_01M476V6DSJ0SD80XPPPRQZZBH", + "deliveryIds": ["524d7c94-9c42-4d20-9d90-8f32a965ffa2"] + } + }, + { + "data": { + "actions": [ + { + "callId": "notebook-review-wait", + "input": { "timeoutSeconds": 2 }, + "kind": "tool-call", + "toolName": "task_wait" + } + ], + "sequence": 1, + "stepIndex": 1, + "turnId": "turn_1" + }, + "type": "actions.requested", + "meta": { + "at": "2026-10-05T23:37:06.235Z", + "id": "evt_01M476V6DV5APK6YK4ARJ56BAJ", + "deliveryIds": ["524d7c94-9c42-4d20-9d90-8f32a965ffa2"] + } + }, + { + "data": { + "finishReason": "tool-calls", + "sequence": 1, + "stepIndex": 1, + "turnId": "turn_1", + "usage": { + "inputTokens": 543, + "outputTokens": 5, + "cacheReadTokens": 0, + "cacheWriteTokens": 0 + } + }, + "type": "step.completed", + "meta": { + "at": "2026-10-05T23:37:06.235Z", + "id": "evt_01M476V6DV5APK6YK4ARJ56BAK", + "deliveryIds": ["524d7c94-9c42-4d20-9d90-8f32a965ffa2"] + } + }, + { + "data": { + "on": "tasks", + "sequence": 1, + "turnId": "turn_1", + "usage": { + "cacheReadTokens": 0, + "cacheWriteTokens": 0, + "inputTokens": 2371, + "outputTokens": 77 + } + }, + "type": "turn.waiting", + "meta": { + "at": "2026-10-05T23:37:06.258Z", + "id": "evt_01M476V6EJ9HJZ2FQTP0S52TPM", + "deliveryIds": ["524d7c94-9c42-4d20-9d90-8f32a965ffa2"] + } + }, + { + "data": { + "result": { + "callId": "notebook-review-wait", + "kind": "tool-result", + "output": "Stopped waiting after 2s; 1 task is still working. Its re...", + "toolName": "task_wait" + }, + "sequence": 1, + "status": "completed", + "stepIndex": 1, + "turnId": "turn_1" + }, + "type": "action.result", + "meta": { + "at": "2026-10-05T23:37:08.294Z", + "id": "evt_01M476V8E667SBF93W9HCSJ4VD", + "deliveryIds": ["524d7c94-9c42-4d20-9d90-8f32a965ffa2"] + } + }, + { + "data": { + "modelId": "eve-mock/notebook-parent", + "sequence": 1, + "stepIndex": 2, + "turnId": "turn_1" + }, + "type": "step.started", + "meta": { + "at": "2026-10-05T23:37:08.295Z", + "id": "evt_01M476V8E7S6HBJVCYS5FSH3SW", + "deliveryIds": ["524d7c94-9c42-4d20-9d90-8f32a965ffa2"] + } + }, + { + "data": { + "actions": [ + { + "callId": "notebook-cancel", + "input": { "taskId": "notebook-keeper-eqtna6" }, + "kind": "tool-call", + "toolName": "task_cancel" + } + ], + "sequence": 1, + "stepIndex": 2, + "turnId": "turn_1" + }, + "type": "actions.requested", + "meta": { + "at": "2026-10-05T23:37:08.298Z", + "id": "evt_01M476V8EAWVYYB5H2Q699KMG6", + "deliveryIds": ["524d7c94-9c42-4d20-9d90-8f32a965ffa2"] + } + }, + { + "data": { + "finishReason": "tool-calls", + "sequence": 1, + "stepIndex": 2, + "turnId": "turn_1", + "usage": { + "inputTokens": 576, + "outputTokens": 9, + "cacheReadTokens": 0, + "cacheWriteTokens": 0 + } + }, + "type": "step.completed", + "meta": { + "at": "2026-10-05T23:37:08.299Z", + "id": "evt_01M476V8EBTW72T7YGSZTNMA8T", + "deliveryIds": ["524d7c94-9c42-4d20-9d90-8f32a965ffa2"] + } + }, + { + "data": { + "callId": "notebook-review", + "kind": "agent", + "name": "notebook-keeper", + "status": "cancelled", + "taskId": "notebook-keeper-eqtna6", + "turnId": "turn_1", + "cancel": { "reason": "task_cancel" } + }, + "type": "task.settled", + "meta": { + "at": "2026-10-05T23:37:08.328Z", + "id": "evt_01M476V8F8Z1FX7TZSZ7ZHDJRX", + "deliveryIds": ["524d7c94-9c42-4d20-9d90-8f32a965ffa2"] + } + }, + { + "data": { + "result": { + "callId": "notebook-cancel", + "kind": "tool-result", + "output": "Stopped notebook-keeper-eqtna6's current work; it won't r...", + "toolName": "task_cancel" + }, + "sequence": 1, + "status": "completed", + "stepIndex": 2, + "turnId": "turn_1" + }, + "type": "action.result", + "meta": { + "at": "2026-10-05T23:37:08.346Z", + "id": "evt_01M476V8FTN14FSP3KYHVCYTRZ", + "deliveryIds": ["524d7c94-9c42-4d20-9d90-8f32a965ffa2"] + } + }, + { + "data": { + "modelId": "eve-mock/notebook-parent", + "sequence": 1, + "stepIndex": 3, + "turnId": "turn_1" + }, + "type": "step.started", + "meta": { + "at": "2026-10-05T23:37:08.346Z", + "id": "evt_01M476V8FTN14FSP3KYHVCYTS0", + "deliveryIds": ["524d7c94-9c42-4d20-9d90-8f32a965ffa2"] + } + }, + { + "data": { + "messageDelta": "NOTEBOOK-REPLY Stopped notebook-keeper-eqtna6's current w...", + "sequence": 1, + "stepIndex": 3, + "turnId": "turn_1" + }, + "type": "message.appended", + "meta": { + "at": "2026-10-05T23:37:08.349Z", + "id": "evt_01M476V8FXZHQQ4Y9YX9JK4MDH", + "deliveryIds": ["524d7c94-9c42-4d20-9d90-8f32a965ffa2"] + } + }, + { + "data": { + "finishReason": "stop", + "message": "NOTEBOOK-REPLY Stopped notebook-keeper-eqtna6's current w...", + "sequence": 1, + "stepIndex": 3, + "turnId": "turn_1" + }, + "type": "message.completed", + "meta": { + "at": "2026-10-05T23:37:08.349Z", + "id": "evt_01M476V8FXZHQQ4Y9YX9JK4MDJ", + "deliveryIds": ["524d7c94-9c42-4d20-9d90-8f32a965ffa2"] + } + }, + { + "data": { + "finishReason": "stop", + "sequence": 1, + "stepIndex": 3, + "turnId": "turn_1", + "usage": { + "inputTokens": 639, + "outputTokens": 42, + "cacheReadTokens": 0, + "cacheWriteTokens": 0 + } + }, + "type": "step.completed", + "meta": { + "at": "2026-10-05T23:37:08.349Z", + "id": "evt_01M476V8FXZHQQ4Y9YX9JK4MDK", + "deliveryIds": ["524d7c94-9c42-4d20-9d90-8f32a965ffa2"] + } + }, + { + "data": { "sequence": 1, "turnId": "turn_1" }, + "type": "turn.completed", + "meta": { + "at": "2026-10-05T23:37:08.353Z", + "id": "evt_01M476V8G10T9NCS0BYG14C6P5", + "deliveryIds": ["524d7c94-9c42-4d20-9d90-8f32a965ffa2"] + } + }, + { + "data": { + "continuationToken": "wrun_01M476V5RJPATF4WMMJD3PAX09", + "usage": { + "cacheReadTokens": 0, + "cacheWriteTokens": 0, + "inputTokens": 3586, + "outputTokens": 128 + }, + "wait": "next-user-message" + }, + "type": "session.waiting", + "meta": { + "at": "2026-10-05T23:37:08.353Z", + "id": "evt_01M476V8G10T9NCS0BYG14C6P6", + "deliveryIds": ["524d7c94-9c42-4d20-9d90-8f32a965ffa2"] + } + }, + { + "data": { "sequence": 2, "turnId": "turn_2" }, + "type": "turn.started", + "meta": { + "at": "2026-10-05T23:37:08.457Z", + "id": "evt_01M476V8K9NZ8YZ6QQKNAH92TD", + "deliveryIds": ["82094b34-d148-4bfe-8b70-5e105267e99b"] + } + }, + { + "data": { + "message": "NOTEBOOK-RECALL notebook-keeper Alice asks for the name.", + "sequence": 2, + "turnId": "turn_2" + }, + "type": "message.received", + "meta": { + "at": "2026-10-05T23:37:08.458Z", + "id": "evt_01M476V8KA3WS00AS63TKXAKKB", + "deliveryIds": ["82094b34-d148-4bfe-8b70-5e105267e99b"] + } + }, + { + "data": { + "modelId": "eve-mock/notebook-parent", + "sequence": 2, + "stepIndex": 0, + "turnId": "turn_2" + }, + "type": "step.started", + "meta": { + "at": "2026-10-05T23:37:08.458Z", + "id": "evt_01M476V8KA3WS00AS63TKXAKKC", + "deliveryIds": ["82094b34-d148-4bfe-8b70-5e105267e99b"] + } + }, + { + "data": { + "actions": [ + { + "callId": "notebook-recall", + "input": { + "message": "NOTEBOOK-ENTRY What name did Alice give the tide station ...", + "taskId": "notebook-keeper-eqtna6" + }, + "kind": "tool-call", + "toolName": "notebook-keeper" + } + ], + "sequence": 2, + "stepIndex": 0, + "turnId": "turn_2" + }, + "type": "actions.requested", + "meta": { + "at": "2026-10-05T23:37:08.460Z", + "id": "evt_01M476V8KCQ4RPNX1HD3Y1C7N8", + "deliveryIds": ["82094b34-d148-4bfe-8b70-5e105267e99b"] + } + }, + { + "data": { + "finishReason": "tool-calls", + "sequence": 2, + "stepIndex": 0, + "turnId": "turn_2", + "usage": { + "inputTokens": 695, + "outputTokens": 29, + "cacheReadTokens": 0, + "cacheWriteTokens": 0 + } + }, + "type": "step.completed", + "meta": { + "at": "2026-10-05T23:37:08.460Z", + "id": "evt_01M476V8KCQ4RPNX1HD3Y1C7N9", + "deliveryIds": ["82094b34-d148-4bfe-8b70-5e105267e99b"] + } + }, + { + "data": { + "callId": "notebook-recall", + "kind": "agent", + "name": "notebook-keeper", + "taskId": "notebook-keeper-eqtna6", + "turnId": "turn_2" + }, + "type": "task.started", + "meta": { + "at": "2026-10-05T23:37:08.486Z", + "id": "evt_01M476V8M6Z762FW3TK0BPFPDC", + "deliveryIds": ["82094b34-d148-4bfe-8b70-5e105267e99b"] + } + }, + { + "data": { + "result": { + "callId": "notebook-recall", + "kind": "tool-result", + "output": "Sent to task notebook-keeper-eqtna6. Its reply will arriv...", + "toolName": "notebook-keeper" + }, + "sequence": 2, + "status": "completed", + "stepIndex": 0, + "turnId": "turn_2" + }, + "type": "action.result", + "meta": { + "at": "2026-10-05T23:37:08.501Z", + "id": "evt_01M476V8MNP1SETGP49BVH3SB6", + "deliveryIds": ["82094b34-d148-4bfe-8b70-5e105267e99b"] + } + }, + { + "data": { + "modelId": "eve-mock/notebook-parent", + "sequence": 2, + "stepIndex": 1, + "turnId": "turn_2" + }, + "type": "step.started", + "meta": { + "at": "2026-10-05T23:37:08.502Z", + "id": "evt_01M476V8MPWG53PDJJX23ZGY1K", + "deliveryIds": ["82094b34-d148-4bfe-8b70-5e105267e99b"] + } + }, + { + "data": { + "actions": [ + { + "callId": "notebook-recall-wait", + "input": {}, + "kind": "tool-call", + "toolName": "task_wait" + } + ], + "sequence": 2, + "stepIndex": 1, + "turnId": "turn_2" + }, + "type": "actions.requested", + "meta": { + "at": "2026-10-05T23:37:08.504Z", + "id": "evt_01M476V8MRFEP96D0MWRXSBG3S", + "deliveryIds": ["82094b34-d148-4bfe-8b70-5e105267e99b"] + } + }, + { + "data": { + "finishReason": "tool-calls", + "sequence": 2, + "stepIndex": 1, + "turnId": "turn_2", + "usage": { + "inputTokens": 742, + "outputTokens": 1, + "cacheReadTokens": 0, + "cacheWriteTokens": 0 + } + }, + "type": "step.completed", + "meta": { + "at": "2026-10-05T23:37:08.504Z", + "id": "evt_01M476V8MRFEP96D0MWRXSBG3T", + "deliveryIds": ["82094b34-d148-4bfe-8b70-5e105267e99b"] + } + }, + { + "data": { + "on": "tasks", + "sequence": 2, + "turnId": "turn_2", + "usage": { + "cacheReadTokens": 0, + "cacheWriteTokens": 0, + "inputTokens": 5023, + "outputTokens": 158 + } + }, + "type": "turn.waiting", + "meta": { + "at": "2026-10-05T23:37:08.520Z", + "id": "evt_01M476V8N872NAGQT8QGWX81SH", + "deliveryIds": ["82094b34-d148-4bfe-8b70-5e105267e99b"] + } + }, + { + "data": { + "callId": "notebook-recall", + "kind": "agent", + "name": "notebook-keeper", + "status": "completed", + "taskId": "notebook-keeper-eqtna6", + "turnId": "turn_2", + "output": "NOTEBOOK-NAME=Harbor Lumen 4482" + }, + "type": "task.settled", + "meta": { + "at": "2026-10-05T23:37:08.630Z", + "id": "evt_01M476V8RPXJGVVE5F24WJ79AF", + "deliveryIds": ["82094b34-d148-4bfe-8b70-5e105267e99b"] + } + }, + { + "data": { + "result": { + "callId": "notebook-recall-wait", + "kind": "tool-result", + "output": "notebook-keeper-eqtna6 completed; its result follows.", + "toolName": "task_wait" + }, + "sequence": 2, + "status": "completed", + "stepIndex": 1, + "turnId": "turn_2" + }, + "type": "action.result", + "meta": { + "at": "2026-10-05T23:37:08.641Z", + "id": "evt_01M476V8S10A6Z1Y50ERZCD2A9", + "deliveryIds": ["82094b34-d148-4bfe-8b70-5e105267e99b"] + } + }, + { + "data": { + "modelId": "eve-mock/notebook-parent", + "sequence": 2, + "stepIndex": 2, + "turnId": "turn_2" + }, + "type": "step.started", + "meta": { + "at": "2026-10-05T23:37:08.642Z", + "id": "evt_01M476V8S2RMQRM83HM2NQ6N86", + "deliveryIds": ["82094b34-d148-4bfe-8b70-5e105267e99b"] + } + }, + { + "data": { + "messageDelta": "NOTEBOOK-REPLY NOTEBOOK-NAME=Harbor Lumen 4482", + "sequence": 2, + "stepIndex": 2, + "turnId": "turn_2" + }, + "type": "message.appended", + "meta": { + "at": "2026-10-05T23:37:08.643Z", + "id": "evt_01M476V8S3ZK1JPMHQEJ5WW503", + "deliveryIds": ["82094b34-d148-4bfe-8b70-5e105267e99b"] + } + }, + { + "data": { + "finishReason": "stop", + "message": "NOTEBOOK-REPLY NOTEBOOK-NAME=Harbor Lumen 4482", + "sequence": 2, + "stepIndex": 2, + "turnId": "turn_2" + }, + "type": "message.completed", + "meta": { + "at": "2026-10-05T23:37:08.644Z", + "id": "evt_01M476V8S40C4W83AANGJFAY22", + "deliveryIds": ["82094b34-d148-4bfe-8b70-5e105267e99b"] + } + }, + { + "data": { + "finishReason": "stop", + "sequence": 2, + "stepIndex": 2, + "turnId": "turn_2", + "usage": { + "inputTokens": 813, + "outputTokens": 12, + "cacheReadTokens": 0, + "cacheWriteTokens": 0 + } + }, + "type": "step.completed", + "meta": { + "at": "2026-10-05T23:37:08.644Z", + "id": "evt_01M476V8S40C4W83AANGJFAY23", + "deliveryIds": ["82094b34-d148-4bfe-8b70-5e105267e99b"] + } + }, + { + "data": { "sequence": 2, "turnId": "turn_2" }, + "type": "turn.completed", + "meta": { + "at": "2026-10-05T23:37:08.648Z", + "id": "evt_01M476V8S8M483PXMJW7TCF6A5", + "deliveryIds": ["82094b34-d148-4bfe-8b70-5e105267e99b"] + } + }, + { + "data": { + "continuationToken": "wrun_01M476V5RJPATF4WMMJD3PAX09", + "usage": { + "cacheReadTokens": 0, + "cacheWriteTokens": 0, + "inputTokens": 6006, + "outputTokens": 178 + }, + "wait": "next-user-message" + }, + "type": "session.waiting", + "meta": { + "at": "2026-10-05T23:37:08.648Z", + "id": "evt_01M476V8S8M483PXMJW7TCF6A6", + "deliveryIds": ["82094b34-d148-4bfe-8b70-5e105267e99b"] + } + } + ] + }, + { + "sessionId": "wrun_01M476V658DK2JBTYGB4XWHDZ0", + "primary": false, + "events": [ + { + "data": { + "runtime": { + "agentId": "notebook-keeper", + "agentName": "notebook-keeper", + "eveVersion": "0.71.2" + } + }, + "type": "session.started", + "meta": { "at": "2026-10-05T23:37:06.009Z", "id": "evt_01M476V66SMTJ5E0FTNQ3KTBAE" } + }, + { + "data": { "sequence": 0, "turnId": "turn_0" }, + "type": "turn.started", + "meta": { "at": "2026-10-05T23:37:06.009Z", "id": "evt_01M476V66SMTJ5E0FTNQ3KTBAF" } + }, + { + "data": { + "message": "You are the subagent \"notebook-keeper\".\nDescription: Test...", + "sequence": 0, + "turnId": "turn_0" + }, + "type": "message.received", + "meta": { "at": "2026-10-05T23:37:06.009Z", "id": "evt_01M476V66SMTJ5E0FTNQ3KTBAG" } + }, + { + "data": { + "modelId": "eve-mock/notebook-keeper", + "sequence": 0, + "stepIndex": 0, + "turnId": "turn_0" + }, + "type": "step.started", + "meta": { "at": "2026-10-05T23:37:06.009Z", "id": "evt_01M476V66SMTJ5E0FTNQ3KTBAH" } + }, + { + "data": { + "messageDelta": "NOTEBOOK-SAVED", + "sequence": 0, + "stepIndex": 0, + "turnId": "turn_0" + }, + "type": "message.appended", + "meta": { "at": "2026-10-05T23:37:06.011Z", "id": "evt_01M476V66VDRJ4S2TSRXESVFGN" } + }, + { + "data": { + "finishReason": "stop", + "message": "NOTEBOOK-SAVED", + "sequence": 0, + "stepIndex": 0, + "turnId": "turn_0" + }, + "type": "message.completed", + "meta": { "at": "2026-10-05T23:37:06.011Z", "id": "evt_01M476V66VDRJ4S2TSRXESVFGP" } + }, + { + "data": { + "finishReason": "stop", + "sequence": 0, + "stepIndex": 0, + "turnId": "turn_0", + "usage": { + "inputTokens": 130, + "outputTokens": 4, + "cacheReadTokens": 0, + "cacheWriteTokens": 0 + } + }, + "type": "step.completed", + "meta": { "at": "2026-10-05T23:37:06.011Z", "id": "evt_01M476V66VDRJ4S2TSRXESVFGQ" } + }, + { + "data": { "sequence": 0, "turnId": "turn_0" }, + "type": "turn.completed", + "meta": { "at": "2026-10-05T23:37:06.017Z", "id": "evt_01M476V671EP9FBHHQBZNAD0CT" } + }, + { + "data": { + "continuationToken": "wrun_01M476V63AHSYGJX1PKPSDJ14K:0", + "usage": { + "cacheReadTokens": 0, + "cacheWriteTokens": 0, + "inputTokens": 130, + "outputTokens": 4 + }, + "wait": "next-user-message" + }, + "type": "session.waiting", + "meta": { "at": "2026-10-05T23:37:06.017Z", "id": "evt_01M476V671EP9FBHHQBZNAD0CV" } + } + ] + }, + { + "sessionId": "wrun_01M476V658DK2JBTYGB4XWHDZ0", + "primary": false, + "events": [ + { + "data": { "sequence": 1, "turnId": "turn_1" }, + "type": "turn.started", + "meta": { "at": "2026-10-05T23:37:06.281Z", "id": "evt_01M476V6F9EBD55ZG238NQ1ZJW" } + }, + { + "data": { + "message": "NOTEBOOK-ENTRY Please review the notebook pages before Al...", + "sequence": 1, + "turnId": "turn_1" + }, + "type": "message.received", + "meta": { "at": "2026-10-05T23:37:06.281Z", "id": "evt_01M476V6F9EBD55ZG238NQ1ZJX" } + }, + { + "data": { + "modelId": "eve-mock/notebook-keeper", + "sequence": 1, + "stepIndex": 0, + "turnId": "turn_1" + }, + "type": "step.started", + "meta": { "at": "2026-10-05T23:37:06.281Z", "id": "evt_01M476V6F9EBD55ZG238NQ1ZJY" } + }, + { + "data": { + "actions": [ + { + "callId": "notebook-review-2", + "input": {}, + "kind": "tool-call", + "toolName": "notebook-review" + } + ], + "sequence": 1, + "stepIndex": 0, + "turnId": "turn_1" + }, + "type": "actions.requested", + "meta": { "at": "2026-10-05T23:37:06.283Z", "id": "evt_01M476V6FBET7W3ABD5M66T5ZH" } + }, + { + "data": { "sequence": 1, "turnId": "turn_1" }, + "type": "turn.cancelled", + "meta": { "at": "2026-10-05T23:37:08.407Z", "id": "evt_01M476V8HQR8KWGGH1VA5BHRGS" } + }, + { + "data": { + "continuationToken": "wrun_01M476V63AHSYGJX1PKPSDJ14K:0", + "usage": { + "cacheReadTokens": 0, + "cacheWriteTokens": 0, + "inputTokens": 130, + "outputTokens": 4 + }, + "wait": "next-user-message" + }, + "type": "session.waiting", + "meta": { "at": "2026-10-05T23:37:08.407Z", "id": "evt_01M476V8HQR8KWGGH1VA5BHRGT" } + } + ] + } + ] + } +} diff --git a/scripts/eval-metrics/fixtures/agent-subagents-nested-authorization.json b/scripts/eval-metrics/fixtures/agent-subagents-nested-authorization.json new file mode 100644 index 0000000000..d069108fb5 --- /dev/null +++ b/scripts/eval-metrics/fixtures/agent-subagents-nested-authorization.json @@ -0,0 +1,412 @@ +{ + "id": "remote-nested-authorization", + "verdict": "passed", + "assertions": [ + { "name": "event(turn.waiting)", "severity": "gate", "score": 1, "passed": true }, + { "name": "event(authorization.required)", "severity": "gate", "score": 1, "passed": true }, + { "name": "notEvent(turn.started)", "severity": "gate", "score": 1, "passed": true }, + { "name": "event(authorization.completed)", "severity": "gate", "score": 1, "passed": true }, + { "name": "noFailedActions", "severity": "gate", "score": 1, "passed": true } + ], + "startedAt": "2026-10-05T23:37:26.612Z", + "completedAt": "2026-10-05T23:37:27.684Z", + "result": { + "sessionId": "wrun_01M476VTAVQCMJKEREDWHYKNMN", + "status": "waiting", + "runtimeIdentity": { + "agentId": "agent-subagents", + "agentName": "agent-subagents", + "eveVersion": "0.71.2" + }, + "sessions": [ + { + "sessionId": "wrun_01M476VTAVQCMJKEREDWHYKNMN", + "primary": true, + "events": [ + { + "data": { + "runtime": { + "agentId": "agent-subagents", + "agentName": "agent-subagents", + "eveVersion": "0.71.2" + } + }, + "type": "session.started", + "meta": { + "at": "2026-10-05T23:37:26.974Z", + "id": "evt_01M476VTNYX4EAXSPBQTK7HB7Q", + "deliveryIds": ["543cda7a-1dd3-4e07-ade1-bf60d757e191"] + } + }, + { + "data": { "sequence": 0, "turnId": "turn_0" }, + "type": "turn.started", + "meta": { + "at": "2026-10-05T23:37:26.974Z", + "id": "evt_01M476VTNYX4EAXSPBQTK7HB7R", + "deliveryIds": ["543cda7a-1dd3-4e07-ade1-bf60d757e191"] + } + }, + { + "data": { + "message": "REMOTE-NESTED-AUTHORIZATION-8H4N: Alice asks the remote a...", + "sequence": 0, + "turnId": "turn_0" + }, + "type": "message.received", + "meta": { + "at": "2026-10-05T23:37:26.974Z", + "id": "evt_01M476VTNYX4EAXSPBQTK7HB7S", + "deliveryIds": ["543cda7a-1dd3-4e07-ade1-bf60d757e191"] + } + }, + { + "data": { + "modelId": "eve-mock/remote-nested", + "sequence": 0, + "stepIndex": 0, + "turnId": "turn_0" + }, + "type": "step.started", + "meta": { + "at": "2026-10-05T23:37:26.974Z", + "id": "evt_01M476VTNYX4EAXSPBQTK7HB7T", + "deliveryIds": ["543cda7a-1dd3-4e07-ade1-bf60d757e191"] + } + }, + { + "data": { + "actions": [ + { + "callId": "nested-remote", + "input": { "message": "CHILD-REMOTE-NESTED-AUTHORIZATION-8H4N" }, + "kind": "tool-call", + "toolName": "remote-loopback" + } + ], + "sequence": 0, + "stepIndex": 0, + "turnId": "turn_0" + }, + "type": "actions.requested", + "meta": { + "at": "2026-10-05T23:37:26.976Z", + "id": "evt_01M476VTP0NSZRSADYBR1QQM1S", + "deliveryIds": ["543cda7a-1dd3-4e07-ade1-bf60d757e191"] + } + }, + { + "data": { + "finishReason": "tool-calls", + "sequence": 0, + "stepIndex": 0, + "turnId": "turn_0", + "usage": { + "inputTokens": 340, + "outputTokens": 13, + "cacheReadTokens": 0, + "cacheWriteTokens": 0 + } + }, + "type": "step.completed", + "meta": { + "at": "2026-10-05T23:37:26.976Z", + "id": "evt_01M476VTP0NSZRSADYBR1QQM1T", + "deliveryIds": ["543cda7a-1dd3-4e07-ade1-bf60d757e191"] + } + }, + { + "data": { + "callId": "nested-remote", + "kind": "agent", + "name": "remote-loopback", + "taskId": "remote-loopback-tf85b0", + "turnId": "turn_0" + }, + "type": "task.started", + "meta": { + "at": "2026-10-05T23:37:26.998Z", + "id": "evt_01M476VTPPADEK17TAEKPG84HC", + "deliveryIds": ["543cda7a-1dd3-4e07-ade1-bf60d757e191"] + } + }, + { + "data": { + "result": { + "callId": "nested-remote", + "kind": "tool-result", + "output": "Started task remote-loopback-tf85b0. Its result will arri...", + "toolName": "remote-loopback" + }, + "sequence": 0, + "status": "completed", + "stepIndex": 0, + "turnId": "turn_0" + }, + "type": "action.result", + "meta": { + "at": "2026-10-05T23:37:27.013Z", + "id": "evt_01M476VTQ546GARN336V0EN9TD", + "deliveryIds": ["543cda7a-1dd3-4e07-ade1-bf60d757e191"] + } + }, + { + "data": { + "modelId": "eve-mock/remote-nested", + "sequence": 0, + "stepIndex": 1, + "turnId": "turn_0" + }, + "type": "step.started", + "meta": { + "at": "2026-10-05T23:37:27.013Z", + "id": "evt_01M476VTQ546GARN336V0EN9TE", + "deliveryIds": ["543cda7a-1dd3-4e07-ade1-bf60d757e191"] + } + }, + { + "data": { + "messageDelta": "The remote release checklist is underway.", + "sequence": 0, + "stepIndex": 1, + "turnId": "turn_0" + }, + "type": "message.appended", + "meta": { + "at": "2026-10-05T23:37:27.015Z", + "id": "evt_01M476VTQ7QQ1K4WPTMZYGG7SN", + "deliveryIds": ["543cda7a-1dd3-4e07-ade1-bf60d757e191"] + } + }, + { + "data": { + "finishReason": "stop", + "message": "The remote release checklist is underway.", + "sequence": 0, + "stepIndex": 1, + "turnId": "turn_0" + }, + "type": "message.completed", + "meta": { + "at": "2026-10-05T23:37:27.015Z", + "id": "evt_01M476VTQ7QQ1K4WPTMZYGG7SP", + "deliveryIds": ["543cda7a-1dd3-4e07-ade1-bf60d757e191"] + } + }, + { + "data": { + "finishReason": "stop", + "sequence": 0, + "stepIndex": 1, + "turnId": "turn_0", + "usage": { + "inputTokens": 410, + "outputTokens": 11, + "cacheReadTokens": 0, + "cacheWriteTokens": 0 + } + }, + "type": "step.completed", + "meta": { + "at": "2026-10-05T23:37:27.015Z", + "id": "evt_01M476VTQ7QQ1K4WPTMZYGG7SQ", + "deliveryIds": ["543cda7a-1dd3-4e07-ade1-bf60d757e191"] + } + }, + { + "data": { + "on": "tasks", + "sequence": 0, + "turnId": "turn_0", + "usage": { + "cacheReadTokens": 0, + "cacheWriteTokens": 0, + "inputTokens": 750, + "outputTokens": 24 + } + }, + "type": "turn.waiting", + "meta": { + "at": "2026-10-05T23:37:27.017Z", + "id": "evt_01M476VTQ9PX49TSEF22101FJX", + "deliveryIds": ["543cda7a-1dd3-4e07-ade1-bf60d757e191"] + } + }, + { + "data": { + "callId": "nested-remote", + "turnId": "turn_0", + "name": "remote-loopback", + "sessionId": "wrun_01M476VTS5RJVZWYPNG596274F", + "taskId": "remote-loopback-tf85b0", + "remote": { + "url": "http://127.0.0.1:65071", + "resolverId": "eve:dynamic-remote-agent//__eve_dynamic_remote_credential..." + } + }, + "type": "agent.started", + "meta": { + "at": "2026-10-05T23:37:27.153Z", + "id": "evt_01M476VTVHHMQ5BPM0HXVKHKNP", + "deliveryIds": ["543cda7a-1dd3-4e07-ade1-bf60d757e191"] + } + }, + { + "type": "authorization.required", + "data": { + "description": "Authorization required for nested-release-authorization", + "name": "nested-release-authorization", + "sequence": 0, + "stepIndex": 0, + "turnId": "turn_0", + "attemptId": "01M476VTZCX1ZYFR346YX7BZ6S", + "principalId": "e2e-user" + }, + "meta": { "at": "2026-10-05T23:37:27.415Z", "id": "evt_01M476VV3QPGXK4EKS47R0E0MW" } + }, + { + "data": { + "on": "input", + "sequence": 0, + "turnId": "turn_0", + "usage": { + "cacheReadTokens": 0, + "cacheWriteTokens": 0, + "inputTokens": 750, + "outputTokens": 24 + } + }, + "type": "turn.waiting", + "meta": { "at": "2026-10-05T23:37:27.415Z", "id": "evt_01M476VV3QPGXK4EKS47R0E0MX" } + } + ] + }, + { + "sessionId": "wrun_01M476VTAVQCMJKEREDWHYKNMN", + "primary": false, + "events": [ + { + "type": "authorization.completed", + "data": { + "name": "nested-release-authorization", + "outcome": "authorized", + "sequence": 0, + "stepIndex": 1, + "turnId": "turn_0", + "attemptId": "01M476VTZCX1ZYFR346YX7BZ6S", + "principalId": "e2e-user" + }, + "meta": { "at": "2026-10-05T23:37:27.589Z", "id": "evt_01M476VV951QJZPK3A32H7X6WQ" } + }, + { + "data": { + "callId": "nested-remote", + "kind": "agent", + "name": "remote-loopback", + "status": "completed", + "taskId": "remote-loopback-tf85b0", + "turnId": "turn_0", + "output": "CHILD-NESTED-AUTHORIZATION-COMPLETE" + }, + "type": "task.settled", + "meta": { + "at": "2026-10-05T23:37:27.665Z", + "id": "evt_01M476VVBHRAW2QXHG7VJR7Z22", + "deliveryIds": ["543cda7a-1dd3-4e07-ade1-bf60d757e191"] + } + }, + { + "data": { + "modelId": "eve-mock/remote-nested", + "sequence": 0, + "stepIndex": 2, + "turnId": "turn_0" + }, + "type": "step.started", + "meta": { + "at": "2026-10-05T23:37:27.676Z", + "id": "evt_01M476VVBW4M2EZC8NRBW4YV53", + "deliveryIds": ["543cda7a-1dd3-4e07-ade1-bf60d757e191"] + } + }, + { + "data": { + "messageDelta": "PARENT-NESTED-COMPLETE: CHILD-NESTED-AUTHORIZATION-COMPLETE", + "sequence": 0, + "stepIndex": 2, + "turnId": "turn_0" + }, + "type": "message.appended", + "meta": { + "at": "2026-10-05T23:37:27.677Z", + "id": "evt_01M476VVBXRABSG9ZMFS9V8DM8", + "deliveryIds": ["543cda7a-1dd3-4e07-ade1-bf60d757e191"] + } + }, + { + "data": { + "finishReason": "stop", + "message": "PARENT-NESTED-COMPLETE: CHILD-NESTED-AUTHORIZATION-COMPLETE", + "sequence": 0, + "stepIndex": 2, + "turnId": "turn_0" + }, + "type": "message.completed", + "meta": { + "at": "2026-10-05T23:37:27.677Z", + "id": "evt_01M476VVBXRABSG9ZMFS9V8DM9", + "deliveryIds": ["543cda7a-1dd3-4e07-ade1-bf60d757e191"] + } + }, + { + "data": { + "finishReason": "stop", + "sequence": 0, + "stepIndex": 2, + "turnId": "turn_0", + "usage": { + "inputTokens": 478, + "outputTokens": 15, + "cacheReadTokens": 0, + "cacheWriteTokens": 0 + } + }, + "type": "step.completed", + "meta": { + "at": "2026-10-05T23:37:27.677Z", + "id": "evt_01M476VVBXRABSG9ZMFS9V8DMA", + "deliveryIds": ["543cda7a-1dd3-4e07-ade1-bf60d757e191"] + } + }, + { + "data": { "sequence": 0, "turnId": "turn_0" }, + "type": "turn.completed", + "meta": { + "at": "2026-10-05T23:37:27.679Z", + "id": "evt_01M476VVBZRVSVX2FPBCRAFXE7", + "deliveryIds": ["543cda7a-1dd3-4e07-ade1-bf60d757e191"] + } + }, + { + "data": { + "continuationToken": "wrun_01M476VTAVQCMJKEREDWHYKNMN", + "usage": { + "cacheReadTokens": 0, + "cacheWriteTokens": 0, + "inputTokens": 2927, + "outputTokens": 79 + }, + "wait": "next-user-message" + }, + "type": "session.waiting", + "meta": { + "at": "2026-10-05T23:37:27.679Z", + "id": "evt_01M476VVBZRVSVX2FPBCRAFXE8", + "deliveryIds": ["543cda7a-1dd3-4e07-ade1-bf60d757e191"] + } + } + ] + } + ] + } +} diff --git a/scripts/eval-metrics/fixtures/agent-subagents-usage-limit-tool.json b/scripts/eval-metrics/fixtures/agent-subagents-usage-limit-tool.json new file mode 100644 index 0000000000..b155fd7121 --- /dev/null +++ b/scripts/eval-metrics/fixtures/agent-subagents-usage-limit-tool.json @@ -0,0 +1,372 @@ +{ + "id": "delegated-usage-limit-tool", + "verdict": "passed", + "assertions": [ + { "name": "requireInputRequest", "severity": "gate", "score": 1, "passed": true }, + { + "name": "satisfies(the parent's input limit prompt counts the worker's tokens)", + "severity": "gate", + "score": 1, + "passed": true + }, + { + "name": "messageIncludes(SURVEY-REPLY Alice's tide survey has 12 stations.)", + "severity": "gate", + "score": 1, + "passed": true + }, + { + "name": "eventsSatisfy(the parent's session.waiting usage counts the worker's tokens)", + "severity": "gate", + "score": 1, + "passed": true + }, + { "name": "calledTool(survey-through-tool)", "severity": "gate", "score": 1, "passed": true } + ], + "startedAt": "2026-10-05T23:37:22.604Z", + "completedAt": "2026-10-05T23:37:22.924Z", + "result": { + "sessionId": "wrun_01M476VPDME7704DBMKWJTEA74", + "status": "waiting", + "runtimeIdentity": { + "agentId": "agent-subagents", + "agentName": "agent-subagents", + "eveVersion": "0.71.2" + }, + "sessions": [ + { + "sessionId": "wrun_01M476VPDME7704DBMKWJTEA74", + "primary": true, + "events": [ + { + "data": { + "runtime": { + "agentId": "agent-subagents", + "agentName": "agent-subagents", + "eveVersion": "0.71.2" + } + }, + "type": "session.started", + "meta": { + "at": "2026-10-05T23:37:22.656Z", + "id": "evt_01M476VPF0V4TY13GCYYZZR7KD", + "deliveryIds": ["70b43cc0-b9b1-45c6-ac8f-0128cf507d99"] + } + }, + { + "data": { "sequence": 0, "turnId": "turn_0" }, + "type": "turn.started", + "meta": { + "at": "2026-10-05T23:37:22.656Z", + "id": "evt_01M476VPF0V4TY13GCYYZZR7KE", + "deliveryIds": ["70b43cc0-b9b1-45c6-ac8f-0128cf507d99"] + } + }, + { + "data": { + "message": "SURVEY-TOOL Alice asks for the tide station survey.", + "sequence": 0, + "turnId": "turn_0" + }, + "type": "message.received", + "meta": { + "at": "2026-10-05T23:37:22.656Z", + "id": "evt_01M476VPF0V4TY13GCYYZZR7KF", + "deliveryIds": ["70b43cc0-b9b1-45c6-ac8f-0128cf507d99"] + } + }, + { + "data": { + "modelId": "eve-mock/survey-tool-parent", + "sequence": 0, + "stepIndex": 0, + "turnId": "turn_0" + }, + "type": "step.started", + "meta": { + "at": "2026-10-05T23:37:22.656Z", + "id": "evt_01M476VPF0V4TY13GCYYZZR7KG", + "deliveryIds": ["70b43cc0-b9b1-45c6-ac8f-0128cf507d99"] + } + }, + { + "data": { + "actions": [ + { + "callId": "survey-tool", + "input": {}, + "kind": "tool-call", + "toolName": "survey-through-tool" + } + ], + "sequence": 0, + "stepIndex": 0, + "turnId": "turn_0" + }, + "type": "actions.requested", + "meta": { + "at": "2026-10-05T23:37:22.657Z", + "id": "evt_01M476VPF1PXQM1A0S8RNM5V38", + "deliveryIds": ["70b43cc0-b9b1-45c6-ac8f-0128cf507d99"] + } + }, + { + "data": { + "finishReason": "tool-calls", + "sequence": 0, + "stepIndex": 0, + "turnId": "turn_0", + "usage": { + "inputTokens": 328, + "outputTokens": 1, + "cacheReadTokens": 0, + "cacheWriteTokens": 0 + } + }, + "type": "step.completed", + "meta": { + "at": "2026-10-05T23:37:22.658Z", + "id": "evt_01M476VPF2X2MGNHE9XETH9JG1", + "deliveryIds": ["70b43cc0-b9b1-45c6-ac8f-0128cf507d99"] + } + }, + { + "data": { + "callId": "survey-tool", + "turnId": "turn_0", + "name": "survey-worker", + "sessionId": "wrun_01M476VPGF63NH2CJXWZ452QV2" + }, + "type": "agent.started", + "meta": { + "at": "2026-10-05T23:37:22.809Z", + "id": "evt_01M476VPKSR2CZ277J0MQ5SGQD", + "deliveryIds": ["70b43cc0-b9b1-45c6-ac8f-0128cf507d99"] + } + }, + { + "data": { + "result": { + "callId": "survey-tool", + "kind": "tool-result", + "output": { "survey": "Alice's tide survey has 12 stations." }, + "toolName": "survey-through-tool" + }, + "sequence": 0, + "status": "completed", + "stepIndex": 0, + "turnId": "turn_0" + }, + "type": "action.result", + "meta": { + "at": "2026-10-05T23:37:22.875Z", + "id": "evt_01M476VPNVZKF29Z9DHZXT08FZ", + "deliveryIds": ["70b43cc0-b9b1-45c6-ac8f-0128cf507d99"] + } + }, + { + "data": { + "modelId": "eve-mock/survey-tool-parent", + "sequence": 0, + "stepIndex": 1, + "turnId": "turn_0" + }, + "type": "step.started", + "meta": { + "at": "2026-10-05T23:37:22.875Z", + "id": "evt_01M476VPNVZKF29Z9DHZXT08G0", + "deliveryIds": ["70b43cc0-b9b1-45c6-ac8f-0128cf507d99"] + } + }, + { + "data": { + "requests": [ + { + "action": { + "callId": "wrun_01M476VPDME7704DBMKWJTEA74:limit:input:50000328", + "input": { "kind": "input", "limit": 40000000, "usedTokens": 50000328 }, + "kind": "tool-call", + "toolName": "session_limit_continuation" + }, + "allowFreeform": false, + "display": "confirmation", + "kind": "session-limit", + "options": [ + { + "description": "Grant a fresh token budget", + "id": "continue", + "label": "Approve", + "style": "primary" + }, + { "description": "Stop now", "id": "stop", "label": "Stop", "style": "danger" } + ], + "prompt": "This session has hit the input-token limit (40M) per sess...", + "requestId": "wrun_01M476VPDME7704DBMKWJTEA74:limit:input:50000328" + } + ], + "sequence": 0, + "stepIndex": 1, + "turnId": "turn_0" + }, + "type": "input.requested", + "meta": { + "at": "2026-10-05T23:37:22.875Z", + "id": "evt_01M476VPNVZKF29Z9DHZXT08G1", + "deliveryIds": ["70b43cc0-b9b1-45c6-ac8f-0128cf507d99"] + } + }, + { + "data": { "sequence": 0, "turnId": "turn_0" }, + "type": "turn.completed", + "meta": { + "at": "2026-10-05T23:37:22.875Z", + "id": "evt_01M476VPNVZKF29Z9DHZXT08G2", + "deliveryIds": ["70b43cc0-b9b1-45c6-ac8f-0128cf507d99"] + } + }, + { + "data": { + "continuationToken": "wrun_01M476VPDME7704DBMKWJTEA74", + "usage": { + "cacheReadTokens": 0, + "cacheWriteTokens": 0, + "inputTokens": 50000328, + "outputTokens": 21 + }, + "wait": "next-user-message" + }, + "type": "session.waiting", + "meta": { + "at": "2026-10-05T23:37:22.876Z", + "id": "evt_01M476VPNWX6ZJ0VPX71CK56SZ", + "deliveryIds": ["70b43cc0-b9b1-45c6-ac8f-0128cf507d99"] + } + }, + { + "data": { + "resolutions": [ + { + "kind": "session-limit", + "outcome": "answered", + "requestId": "wrun_01M476VPDME7704DBMKWJTEA74:limit:input:50000328", + "response": { + "optionId": "continue", + "requestId": "wrun_01M476VPDME7704DBMKWJTEA74:limit:input:50000328" + } + } + ], + "sequence": 0, + "stepIndex": 1, + "turnId": "turn_0" + }, + "type": "input.resolved", + "meta": { + "at": "2026-10-05T23:37:22.914Z", + "id": "evt_01M476VPQ29R903C8GSYRQF456", + "deliveryIds": ["70b43cc0-b9b1-45c6-ac8f-0128cf507d99"] + } + }, + { + "data": { "sequence": 1, "turnId": "turn_1" }, + "type": "turn.started", + "meta": { + "at": "2026-10-05T23:37:22.914Z", + "id": "evt_01M476VPQ29R903C8GSYRQF457", + "deliveryIds": ["70b43cc0-b9b1-45c6-ac8f-0128cf507d99"] + } + }, + { + "data": { + "modelId": "eve-mock/survey-tool-parent", + "sequence": 1, + "stepIndex": 0, + "turnId": "turn_1" + }, + "type": "step.started", + "meta": { + "at": "2026-10-05T23:37:22.915Z", + "id": "evt_01M476VPQ3NDV9YAQK95HMSN45", + "deliveryIds": ["70b43cc0-b9b1-45c6-ac8f-0128cf507d99"] + } + }, + { + "data": { + "messageDelta": "SURVEY-REPLY Alice's tide survey has 12 stations.", + "sequence": 1, + "stepIndex": 0, + "turnId": "turn_1" + }, + "type": "message.appended", + "meta": { + "at": "2026-10-05T23:37:22.916Z", + "id": "evt_01M476VPQ4W836SYTV426TM8BM", + "deliveryIds": ["70b43cc0-b9b1-45c6-ac8f-0128cf507d99"] + } + }, + { + "data": { + "finishReason": "stop", + "message": "SURVEY-REPLY Alice's tide survey has 12 stations.", + "sequence": 1, + "stepIndex": 0, + "turnId": "turn_1" + }, + "type": "message.completed", + "meta": { + "at": "2026-10-05T23:37:22.916Z", + "id": "evt_01M476VPQ4W836SYTV426TM8BN", + "deliveryIds": ["70b43cc0-b9b1-45c6-ac8f-0128cf507d99"] + } + }, + { + "data": { + "finishReason": "stop", + "sequence": 1, + "stepIndex": 0, + "turnId": "turn_1", + "usage": { + "inputTokens": 341, + "outputTokens": 13, + "cacheReadTokens": 0, + "cacheWriteTokens": 0 + } + }, + "type": "step.completed", + "meta": { + "at": "2026-10-05T23:37:22.916Z", + "id": "evt_01M476VPQ4W836SYTV426TM8BP", + "deliveryIds": ["70b43cc0-b9b1-45c6-ac8f-0128cf507d99"] + } + }, + { + "data": { "sequence": 1, "turnId": "turn_1" }, + "type": "turn.completed", + "meta": { + "at": "2026-10-05T23:37:22.919Z", + "id": "evt_01M476VPQ78AV8D3VPRGJ5XW9K", + "deliveryIds": ["70b43cc0-b9b1-45c6-ac8f-0128cf507d99"] + } + }, + { + "data": { + "continuationToken": "wrun_01M476VPDME7704DBMKWJTEA74", + "usage": { + "cacheReadTokens": 0, + "cacheWriteTokens": 0, + "inputTokens": 50000669, + "outputTokens": 34 + }, + "wait": "next-user-message" + }, + "type": "session.waiting", + "meta": { + "at": "2026-10-05T23:37:22.919Z", + "id": "evt_01M476VPQ78AV8D3VPRGJ5XW9M", + "deliveryIds": ["70b43cc0-b9b1-45c6-ac8f-0128cf507d99"] + } + } + ] + } + ] + } +} diff --git a/scripts/eval-metrics/fixtures/agent-tools-compacted-image.json b/scripts/eval-metrics/fixtures/agent-tools-compacted-image.json new file mode 100644 index 0000000000..26f06fde86 --- /dev/null +++ b/scripts/eval-metrics/fixtures/agent-tools-compacted-image.json @@ -0,0 +1,490 @@ +{ + "id": "static-tools/read-file-reopens-compacted-image", + "verdict": "passed", + "assertions": [ + { "name": "calledTool(render-stripes)", "severity": "gate", "score": 1, "passed": true }, + { "name": "requireToolCall", "severity": "gate", "score": 1, "passed": true }, + { "name": "event(compaction.completed)", "severity": "gate", "score": 1, "passed": true }, + { "name": "notEvent(session.failed)", "severity": "gate", "score": 1, "passed": true }, + { "name": "noFailedActions", "severity": "gate", "score": 1, "passed": true }, + { "name": "calledTool(read_file)", "severity": "gate", "score": 1, "passed": true }, + { "name": "notCalledTool(render-stripes)", "severity": "gate", "score": 1, "passed": true }, + { + "name": "eventsSatisfy(the reopened image answers the follow-up)", + "severity": "soft", + "score": 1, + "passed": true + } + ], + "startedAt": "2026-10-05T23:35:48.752Z", + "completedAt": "2026-10-05T23:36:12.742Z", + "result": { + "sessionId": "wrun_01M476RTSMZ429787CBW4VYEF8", + "status": "waiting", + "runtimeIdentity": { + "agentId": "agent-tools", + "agentName": "agent-tools", + "eveVersion": "0.71.2" + }, + "sessions": [ + { + "sessionId": "wrun_01M476RTSMZ429787CBW4VYEF8", + "primary": true, + "events": [ + { + "data": { + "runtime": { + "agentId": "agent-tools", + "agentName": "agent-tools", + "eveVersion": "0.71.2" + } + }, + "type": "session.started", + "meta": { + "at": "2026-10-05T23:35:49.127Z", + "id": "evt_01M476RV47J2BQS0D5CDYZA22S", + "deliveryIds": ["36d81bed-1457-417c-b5d8-3f8ef877532f"] + } + }, + { + "data": { "sequence": 0, "turnId": "turn_0" }, + "type": "turn.started", + "meta": { + "at": "2026-10-05T23:35:49.127Z", + "id": "evt_01M476RV47J2BQS0D5CDYZA22T", + "deliveryIds": ["36d81bed-1457-417c-b5d8-3f8ef877532f"] + } + }, + { + "data": { + "message": "Call `render-stripes` exactly once, look at the rendered ...", + "sequence": 0, + "turnId": "turn_0" + }, + "type": "message.received", + "meta": { + "at": "2026-10-05T23:35:49.127Z", + "id": "evt_01M476RV47J2BQS0D5CDYZA22V", + "deliveryIds": ["36d81bed-1457-417c-b5d8-3f8ef877532f"] + } + }, + { + "data": { + "modelId": "eve-mock/model", + "sequence": 0, + "stepIndex": 0, + "turnId": "turn_0" + }, + "type": "step.started", + "meta": { + "at": "2026-10-05T23:35:49.127Z", + "id": "evt_01M476RV47J2BQS0D5CDYZA22W", + "deliveryIds": ["36d81bed-1457-417c-b5d8-3f8ef877532f"] + } + }, + { + "data": { + "actions": [ + { + "callId": "mock-tool-call-1-0-1", + "input": {}, + "kind": "tool-call", + "toolName": "render-stripes" + } + ], + "sequence": 0, + "stepIndex": 0, + "turnId": "turn_0" + }, + "type": "actions.requested", + "meta": { + "at": "2026-10-05T23:35:49.129Z", + "id": "evt_01M476RV49FA0N1CTNMT3VDWX2", + "deliveryIds": ["36d81bed-1457-417c-b5d8-3f8ef877532f"] + } + }, + { + "data": { + "result": { + "callId": "mock-tool-call-1-0-1", + "kind": "tool-result", + "output": { "colors": ["orange", "green", "red"] }, + "toolName": "render-stripes" + }, + "sequence": 0, + "status": "completed", + "stepIndex": 0, + "turnId": "turn_0" + }, + "type": "action.result", + "meta": { + "at": "2026-10-05T23:35:49.131Z", + "id": "evt_01M476RV4B5YSVWM8C9VQZ0VRV", + "deliveryIds": ["36d81bed-1457-417c-b5d8-3f8ef877532f"] + } + }, + { + "data": { + "finishReason": "tool-calls", + "sequence": 0, + "stepIndex": 0, + "turnId": "turn_0", + "usage": { + "inputTokens": 349, + "outputTokens": 1, + "cacheReadTokens": 0, + "cacheWriteTokens": 0 + } + }, + "type": "step.completed", + "meta": { + "at": "2026-10-05T23:35:49.131Z", + "id": "evt_01M476RV4B5YSVWM8C9VQZ0VRW", + "deliveryIds": ["36d81bed-1457-417c-b5d8-3f8ef877532f"] + } + }, + { + "data": { + "modelId": "eve-mock/model", + "sequence": 0, + "stepIndex": 1, + "turnId": "turn_0" + }, + "type": "step.started", + "meta": { + "at": "2026-10-05T23:36:12.463Z", + "id": "evt_01M476SHXF25N68JJMMK05VRCX", + "deliveryIds": ["36d81bed-1457-417c-b5d8-3f8ef877532f"] + } + }, + { + "data": { + "messageDelta": "orange, green, red", + "sequence": 0, + "stepIndex": 1, + "turnId": "turn_0" + }, + "type": "message.appended", + "meta": { + "at": "2026-10-05T23:36:12.468Z", + "id": "evt_01M476SHXMWNG85J0J0HFFX5AM", + "deliveryIds": ["36d81bed-1457-417c-b5d8-3f8ef877532f"] + } + }, + { + "data": { + "finishReason": "stop", + "message": "orange, green, red", + "sequence": 0, + "stepIndex": 1, + "turnId": "turn_0" + }, + "type": "message.completed", + "meta": { + "at": "2026-10-05T23:36:12.468Z", + "id": "evt_01M476SHXMWNG85J0J0HFFX5AN", + "deliveryIds": ["36d81bed-1457-417c-b5d8-3f8ef877532f"] + } + }, + { + "data": { + "finishReason": "stop", + "sequence": 0, + "stepIndex": 1, + "turnId": "turn_0", + "usage": { + "inputTokens": 554, + "outputTokens": 5, + "cacheReadTokens": 0, + "cacheWriteTokens": 0 + } + }, + "type": "step.completed", + "meta": { + "at": "2026-10-05T23:36:12.469Z", + "id": "evt_01M476SHXNBE7EZQ0EX1DJ8329", + "deliveryIds": ["36d81bed-1457-417c-b5d8-3f8ef877532f"] + } + }, + { + "data": { "sequence": 0, "turnId": "turn_0" }, + "type": "turn.completed", + "meta": { + "at": "2026-10-05T23:36:12.475Z", + "id": "evt_01M476SHXV5Q9Y3JCNFDM1JJ9N", + "deliveryIds": ["36d81bed-1457-417c-b5d8-3f8ef877532f"] + } + }, + { + "data": { + "continuationToken": "wrun_01M476RTSMZ429787CBW4VYEF8", + "usage": { + "cacheReadTokens": 0, + "cacheWriteTokens": 0, + "inputTokens": 903, + "outputTokens": 6 + }, + "wait": "next-user-message" + }, + "type": "session.waiting", + "meta": { + "at": "2026-10-05T23:36:12.475Z", + "id": "evt_01M476SHXV5Q9Y3JCNFDM1JJ9P", + "deliveryIds": ["36d81bed-1457-417c-b5d8-3f8ef877532f"] + } + }, + { + "data": { "sequence": 1, "turnId": "turn_1" }, + "type": "turn.started", + "meta": { + "at": "2026-10-05T23:36:12.710Z", + "id": "evt_01M476SJ56X8MVZ260SZZ0WR3S", + "deliveryIds": ["304cb8c1-c82a-49ba-ae31-65bcb7413ae5"] + } + }, + { + "data": { + "message": "Earlier context was compacted. The `render-stripes` image...", + "sequence": 1, + "turnId": "turn_1" + }, + "type": "message.received", + "meta": { + "at": "2026-10-05T23:36:12.710Z", + "id": "evt_01M476SJ56X8MVZ260SZZ0WR3T", + "deliveryIds": ["304cb8c1-c82a-49ba-ae31-65bcb7413ae5"] + } + }, + { + "data": { + "modelId": "eve-mock/model", + "sequence": 1, + "stepIndex": 0, + "turnId": "turn_1" + }, + "type": "step.started", + "meta": { + "at": "2026-10-05T23:36:12.710Z", + "id": "evt_01M476SJ56X8MVZ260SZZ0WR3V", + "deliveryIds": ["304cb8c1-c82a-49ba-ae31-65bcb7413ae5"] + } + }, + { + "data": { + "actions": [ + { + "callId": "mock-tool-call-3-0-1", + "input": { + "filePath": "/workspace/.eve/attachments/73db7807c156ef3e/stripes.png" + }, + "kind": "tool-call", + "toolName": "read_file" + } + ], + "sequence": 1, + "stepIndex": 0, + "turnId": "turn_1" + }, + "type": "actions.requested", + "meta": { + "at": "2026-10-05T23:36:12.712Z", + "id": "evt_01M476SJ58NDMKMNJHRGKGK2GG", + "deliveryIds": ["304cb8c1-c82a-49ba-ae31-65bcb7413ae5"] + } + }, + { + "data": { + "result": { + "callId": "mock-tool-call-3-0-1", + "kind": "tool-result", + "output": { + "content": "Image /workspace/.eve/attachments/73db7807c156ef3e/stripe...", + "image": { "mediaType": "image/png", "size": 507, "height": 120, "width": 240 }, + "path": "/workspace/.eve/attachments/73db7807c156ef3e/stripes.png", + "totalLines": 0, + "truncated": false + }, + "toolName": "read_file" + }, + "sequence": 1, + "status": "completed", + "stepIndex": 0, + "turnId": "turn_1" + }, + "type": "action.result", + "meta": { + "at": "2026-10-05T23:36:12.717Z", + "id": "evt_01M476SJ5DPQTNMCVK2YAX29J7", + "deliveryIds": ["304cb8c1-c82a-49ba-ae31-65bcb7413ae5"] + } + }, + { + "data": { + "finishReason": "tool-calls", + "sequence": 1, + "stepIndex": 0, + "turnId": "turn_1", + "usage": { + "inputTokens": 446, + "outputTokens": 18, + "cacheReadTokens": 0, + "cacheWriteTokens": 0 + } + }, + "type": "step.completed", + "meta": { + "at": "2026-10-05T23:36:12.719Z", + "id": "evt_01M476SJ5F9N9GY9ZGZNQ0VB3S", + "deliveryIds": ["304cb8c1-c82a-49ba-ae31-65bcb7413ae5"] + } + }, + { + "data": { + "modelId": "eve-mock/model", + "sequence": 1, + "stepIndex": 1, + "turnId": "turn_1" + }, + "type": "step.started", + "meta": { + "at": "2026-10-05T23:36:12.731Z", + "id": "evt_01M476SJ5VZWADXTQG3SHRX2FR", + "deliveryIds": ["304cb8c1-c82a-49ba-ae31-65bcb7413ae5"] + } + }, + { + "data": { + "messageDelta": "orange, green, red", + "sequence": 1, + "stepIndex": 1, + "turnId": "turn_1" + }, + "type": "message.appended", + "meta": { + "at": "2026-10-05T23:36:12.734Z", + "id": "evt_01M476SJ5YMYMVNTYVC38D21SV", + "deliveryIds": ["304cb8c1-c82a-49ba-ae31-65bcb7413ae5"] + } + }, + { + "data": { + "finishReason": "stop", + "message": "orange, green, red", + "sequence": 1, + "stepIndex": 1, + "turnId": "turn_1" + }, + "type": "message.completed", + "meta": { + "at": "2026-10-05T23:36:12.735Z", + "id": "evt_01M476SJ5ZN64X33Y3T1CKVM3H", + "deliveryIds": ["304cb8c1-c82a-49ba-ae31-65bcb7413ae5"] + } + }, + { + "data": { + "finishReason": "stop", + "sequence": 1, + "stepIndex": 1, + "turnId": "turn_1", + "usage": { + "inputTokens": 668, + "outputTokens": 5, + "cacheReadTokens": 0, + "cacheWriteTokens": 0 + } + }, + "type": "step.completed", + "meta": { + "at": "2026-10-05T23:36:12.735Z", + "id": "evt_01M476SJ5ZN64X33Y3T1CKVM3J", + "deliveryIds": ["304cb8c1-c82a-49ba-ae31-65bcb7413ae5"] + } + }, + { + "data": { "sequence": 1, "turnId": "turn_1" }, + "type": "turn.completed", + "meta": { + "at": "2026-10-05T23:36:12.738Z", + "id": "evt_01M476SJ622119T7V1AY0XSG67", + "deliveryIds": ["304cb8c1-c82a-49ba-ae31-65bcb7413ae5"] + } + }, + { + "data": { + "continuationToken": "wrun_01M476RTSMZ429787CBW4VYEF8", + "usage": { + "cacheReadTokens": 0, + "cacheWriteTokens": 0, + "inputTokens": 2017, + "outputTokens": 29 + }, + "wait": "next-user-message" + }, + "type": "session.waiting", + "meta": { + "at": "2026-10-05T23:36:12.738Z", + "id": "evt_01M476SJ622119T7V1AY0XSG68", + "deliveryIds": ["304cb8c1-c82a-49ba-ae31-65bcb7413ae5"] + } + } + ] + }, + { + "sessionId": "wrun_01M476RTSMZ429787CBW4VYEF8", + "primary": false, + "events": [ + { + "data": { + "modelId": "eve-mock/model", + "sequence": 1, + "sessionId": "wrun_01M476RTSMZ429787CBW4VYEF8", + "stepIndex": 0, + "turnId": "turn_1", + "usageInputTokens": 573.5 + }, + "type": "compaction.requested", + "meta": { + "at": "2026-10-05T23:36:12.533Z", + "id": "evt_01M476SHZN5GCNRBNCC3NYQ0X5", + "deliveryIds": ["36d81bed-1457-417c-b5d8-3f8ef877532f"] + } + }, + { + "data": { + "modelId": "eve-mock/model", + "sequence": 1, + "sessionId": "wrun_01M476RTSMZ429787CBW4VYEF8", + "stepIndex": 0, + "turnId": "turn_1" + }, + "type": "compaction.completed", + "meta": { + "at": "2026-10-05T23:36:12.535Z", + "id": "evt_01M476SHZQVY6HWY4S5NE78NSZ", + "deliveryIds": ["36d81bed-1457-417c-b5d8-3f8ef877532f"] + } + }, + { + "data": { + "continuationToken": "wrun_01M476RTSMZ429787CBW4VYEF8", + "usage": { + "cacheReadTokens": 0, + "cacheWriteTokens": 0, + "inputTokens": 903, + "outputTokens": 6 + }, + "wait": "next-user-message" + }, + "type": "session.waiting", + "meta": { + "at": "2026-10-05T23:36:12.535Z", + "id": "evt_01M476SHZQVY6HWY4S5NE78NT0", + "deliveryIds": ["36d81bed-1457-417c-b5d8-3f8ef877532f"] + } + } + ] + } + ] + } +} diff --git a/scripts/eval-metrics/fixtures/agent-tools-ends-turn-function.json b/scripts/eval-metrics/fixtures/agent-tools-ends-turn-function.json new file mode 100644 index 0000000000..0f6fb26ded --- /dev/null +++ b/scripts/eval-metrics/fixtures/agent-tools-ends-turn-function.json @@ -0,0 +1,366 @@ +{ + "id": "static-tools/ends-turn-function", + "verdict": "passed", + "assertions": [ + { "name": "requireToolCall", "severity": "gate", "score": 1, "passed": true }, + { + "name": "satisfies(a posted reaction sends no reply)", + "severity": "gate", + "score": 1, + "passed": true + }, + { "name": "requireToolCall", "severity": "gate", "score": 1, "passed": true }, + { + "name": "satisfies(a reaction that doesn't post gets a reply)", + "severity": "gate", + "score": 1, + "passed": true + }, + { "name": "succeeded", "severity": "gate", "score": 1, "passed": true } + ], + "startedAt": "2026-10-05T23:35:48.752Z", + "completedAt": "2026-10-05T23:35:49.802Z", + "result": { + "sessionId": "wrun_01M476RTSJ569Y4TN0BBRB2ED0", + "status": "waiting", + "runtimeIdentity": { + "agentId": "agent-tools", + "agentName": "agent-tools", + "eveVersion": "0.71.2" + }, + "sessions": [ + { + "sessionId": "wrun_01M476RTSJ569Y4TN0BBRB2ED0", + "primary": true, + "events": [ + { + "data": { + "runtime": { + "agentId": "agent-tools", + "agentName": "agent-tools", + "eveVersion": "0.71.2" + } + }, + "type": "session.started", + "meta": { + "at": "2026-10-05T23:35:49.107Z", + "id": "evt_01M476RV3K4ZKVSF6W5BES4F4E", + "deliveryIds": ["640fe784-388f-4d0f-83eb-4ac814750db5"] + } + }, + { + "data": { "sequence": 0, "turnId": "turn_0" }, + "type": "turn.started", + "meta": { + "at": "2026-10-05T23:35:49.110Z", + "id": "evt_01M476RV3PFF8YGR8F46VHK6NW", + "deliveryIds": ["640fe784-388f-4d0f-83eb-4ac814750db5"] + } + }, + { + "data": { + "message": "Alice shared that the release shipped this morning. Celeb...", + "sequence": 0, + "turnId": "turn_0" + }, + "type": "message.received", + "meta": { + "at": "2026-10-05T23:35:49.111Z", + "id": "evt_01M476RV3QE6HEZ69D6D7MTEX8", + "deliveryIds": ["640fe784-388f-4d0f-83eb-4ac814750db5"] + } + }, + { + "data": { + "modelId": "eve-mock/model", + "sequence": 0, + "stepIndex": 0, + "turnId": "turn_0" + }, + "type": "step.started", + "meta": { + "at": "2026-10-05T23:35:49.112Z", + "id": "evt_01M476RV3RZCCW92ZXEJWBW8WM", + "deliveryIds": ["640fe784-388f-4d0f-83eb-4ac814750db5"] + } + }, + { + "data": { + "actions": [ + { + "callId": "mock-tool-call-1-0-1", + "input": { "emoji": "tada" }, + "kind": "tool-call", + "toolName": "react_to_note" + } + ], + "sequence": 0, + "stepIndex": 0, + "turnId": "turn_0" + }, + "type": "actions.requested", + "meta": { + "at": "2026-10-05T23:35:49.121Z", + "id": "evt_01M476RV41G16Z7FSF37GAG635", + "deliveryIds": ["640fe784-388f-4d0f-83eb-4ac814750db5"] + } + }, + { + "data": { + "result": { + "callId": "mock-tool-call-1-0-1", + "kind": "tool-result", + "output": { "emoji": "tada", "posted": true }, + "toolName": "react_to_note" + }, + "sequence": 0, + "status": "completed", + "stepIndex": 0, + "turnId": "turn_0" + }, + "type": "action.result", + "meta": { + "at": "2026-10-05T23:35:49.123Z", + "id": "evt_01M476RV4325FKVY9H3VTMBSEW", + "deliveryIds": ["640fe784-388f-4d0f-83eb-4ac814750db5"] + } + }, + { + "data": { + "finishReason": "tool-calls", + "sequence": 0, + "stepIndex": 0, + "turnId": "turn_0", + "usage": { + "inputTokens": 344, + "outputTokens": 4, + "cacheReadTokens": 0, + "cacheWriteTokens": 0 + } + }, + "type": "step.completed", + "meta": { + "at": "2026-10-05T23:35:49.125Z", + "id": "evt_01M476RV45DWXA28BBVBFY5HQF", + "deliveryIds": ["640fe784-388f-4d0f-83eb-4ac814750db5"] + } + }, + { + "data": { "sequence": 0, "turnId": "turn_0" }, + "type": "turn.completed", + "meta": { + "at": "2026-10-05T23:35:49.141Z", + "id": "evt_01M476RV4NAD1MEWDF4H2CBQWB", + "deliveryIds": ["640fe784-388f-4d0f-83eb-4ac814750db5"] + } + }, + { + "data": { + "continuationToken": "wrun_01M476RTSJ569Y4TN0BBRB2ED0", + "usage": { + "cacheReadTokens": 0, + "cacheWriteTokens": 0, + "inputTokens": 344, + "outputTokens": 4 + }, + "wait": "next-user-message" + }, + "type": "session.waiting", + "meta": { + "at": "2026-10-05T23:35:49.141Z", + "id": "evt_01M476RV4NAD1MEWDF4H2CBQWC", + "deliveryIds": ["640fe784-388f-4d0f-83eb-4ac814750db5"] + } + }, + { + "data": { "sequence": 1, "turnId": "turn_1" }, + "type": "turn.started", + "meta": { + "at": "2026-10-05T23:35:49.709Z", + "id": "evt_01M476RVPD601TWZ4WEK2RYKCG", + "deliveryIds": ["80bb6d77-ce6f-47a4-8096-d50bd640b017"] + } + }, + { + "data": { + "message": "Bob would also like to add a rocket reaction to Alice's n...", + "sequence": 1, + "turnId": "turn_1" + }, + "type": "message.received", + "meta": { + "at": "2026-10-05T23:35:49.709Z", + "id": "evt_01M476RVPD601TWZ4WEK2RYKCH", + "deliveryIds": ["80bb6d77-ce6f-47a4-8096-d50bd640b017"] + } + }, + { + "data": { + "modelId": "eve-mock/model", + "sequence": 1, + "stepIndex": 0, + "turnId": "turn_1" + }, + "type": "step.started", + "meta": { + "at": "2026-10-05T23:35:49.709Z", + "id": "evt_01M476RVPD601TWZ4WEK2RYKCJ", + "deliveryIds": ["80bb6d77-ce6f-47a4-8096-d50bd640b017"] + } + }, + { + "data": { + "actions": [ + { + "callId": "mock-tool-call-2-1-1", + "input": { "emoji": "rocket" }, + "kind": "tool-call", + "toolName": "react_to_note" + } + ], + "sequence": 1, + "stepIndex": 0, + "turnId": "turn_1" + }, + "type": "actions.requested", + "meta": { + "at": "2026-10-05T23:35:49.711Z", + "id": "evt_01M476RVPFWRV23DNSPJERPZRX", + "deliveryIds": ["80bb6d77-ce6f-47a4-8096-d50bd640b017"] + } + }, + { + "data": { + "result": { + "callId": "mock-tool-call-2-1-1", + "kind": "tool-result", + "output": { "emoji": "rocket", "posted": false }, + "toolName": "react_to_note" + }, + "sequence": 1, + "status": "completed", + "stepIndex": 0, + "turnId": "turn_1" + }, + "type": "action.result", + "meta": { + "at": "2026-10-05T23:35:49.712Z", + "id": "evt_01M476RVPGVTS0HK0BT3DHYRNB", + "deliveryIds": ["80bb6d77-ce6f-47a4-8096-d50bd640b017"] + } + }, + { + "data": { + "finishReason": "tool-calls", + "sequence": 1, + "stepIndex": 0, + "turnId": "turn_1", + "usage": { + "inputTokens": 382, + "outputTokens": 5, + "cacheReadTokens": 0, + "cacheWriteTokens": 0 + } + }, + "type": "step.completed", + "meta": { + "at": "2026-10-05T23:35:49.712Z", + "id": "evt_01M476RVPGVTS0HK0BT3DHYRNC", + "deliveryIds": ["80bb6d77-ce6f-47a4-8096-d50bd640b017"] + } + }, + { + "data": { + "modelId": "eve-mock/model", + "sequence": 1, + "stepIndex": 1, + "turnId": "turn_1" + }, + "type": "step.started", + "meta": { + "at": "2026-10-05T23:35:49.780Z", + "id": "evt_01M476RVRMAYSTN1F5WH1BDHGM", + "deliveryIds": ["80bb6d77-ce6f-47a4-8096-d50bd640b017"] + } + }, + { + "data": { + "messageDelta": "The rocket reaction isn't allowed in the team channel, so...", + "sequence": 1, + "stepIndex": 1, + "turnId": "turn_1" + }, + "type": "message.appended", + "meta": { + "at": "2026-10-05T23:35:49.784Z", + "id": "evt_01M476RVRRACG4V1029E45SH2D", + "deliveryIds": ["80bb6d77-ce6f-47a4-8096-d50bd640b017"] + } + }, + { + "data": { + "finishReason": "stop", + "message": "The rocket reaction isn't allowed in the team channel, so...", + "sequence": 1, + "stepIndex": 1, + "turnId": "turn_1" + }, + "type": "message.completed", + "meta": { + "at": "2026-10-05T23:35:49.784Z", + "id": "evt_01M476RVRRACG4V1029E45SH2F", + "deliveryIds": ["80bb6d77-ce6f-47a4-8096-d50bd640b017"] + } + }, + { + "data": { + "finishReason": "stop", + "sequence": 1, + "stepIndex": 1, + "turnId": "turn_1", + "usage": { + "inputTokens": 391, + "outputTokens": 20, + "cacheReadTokens": 0, + "cacheWriteTokens": 0 + } + }, + "type": "step.completed", + "meta": { + "at": "2026-10-05T23:35:49.784Z", + "id": "evt_01M476RVRRACG4V1029E45SH2H", + "deliveryIds": ["80bb6d77-ce6f-47a4-8096-d50bd640b017"] + } + }, + { + "data": { "sequence": 1, "turnId": "turn_1" }, + "type": "turn.completed", + "meta": { + "at": "2026-10-05T23:35:49.794Z", + "id": "evt_01M476RVS25WY95VK1961T35DH", + "deliveryIds": ["80bb6d77-ce6f-47a4-8096-d50bd640b017"] + } + }, + { + "data": { + "continuationToken": "wrun_01M476RTSJ569Y4TN0BBRB2ED0", + "usage": { + "cacheReadTokens": 0, + "cacheWriteTokens": 0, + "inputTokens": 1117, + "outputTokens": 29 + }, + "wait": "next-user-message" + }, + "type": "session.waiting", + "meta": { + "at": "2026-10-05T23:35:49.794Z", + "id": "evt_01M476RVS25WY95VK1961T35DJ", + "deliveryIds": ["80bb6d77-ce6f-47a4-8096-d50bd640b017"] + } + } + ] + } + ] + } +} diff --git a/scripts/eval-metrics/gaps.mjs b/scripts/eval-metrics/gaps.mjs new file mode 100644 index 0000000000..e7ff90632d --- /dev/null +++ b/scripts/eval-metrics/gaps.mjs @@ -0,0 +1,81 @@ +// Outcome classification for one eval attempt. A gap is an attempt that says +// nothing about the model or eve: it counts toward coverage but is excluded +// from pass rates and from latency and cost medians. + +/** Every gap reason, in classification priority order. */ +export const GAP_REASONS = Object.freeze([ + // The publish job found no artifact for a planned leg. + "job-missing", + // A planned eval has no result in its leg's artifacts. + "eval-missing", + // The model provider or network failed: HTTP 429/5xx, or a network-level error. + "provider-unavailable", + // eve could not write its durable event stream. + "stream-write-failed", + // The eval timed out before any model step completed. + "timeout-before-first-step", + // The eval errored before any model step completed. + "error-before-first-step", +]); + +/** @typedef {"completed" | "timed_out" | "skipped" | "parked" | "gap"} Outcome */ + +const FAILURE_EVENTS = new Set(["step.failed", "turn.failed", "session.failed"]); +const NETWORK_FAILURE = + /\b(UND_ERR_(?:BODY|CONNECT|HEADERS)_TIMEOUT|UND_ERR_SOCKET|ECONNRESET|ECONNREFUSED|ETIMEDOUT|EAI_AGAIN|ENOTFOUND)\b/; +const TIMEOUT_ERROR = /aborted due to timeout|TimeoutError/i; + +/** + * @param {object} evalArtifact parsed eval detail JSON + * @returns {{ outcome: Outcome, gap_reason?: string }} + */ +export function classifyOutcome(evalArtifact) { + if (evalArtifact?.verdict === "skipped") return { outcome: "skipped" }; + + const events = (evalArtifact?.result?.sessions ?? []).flatMap((session) => session.events ?? []); + const failures = events.filter((event) => FAILURE_EVENTS.has(event.type)); + if (failures.some(isProviderFailure)) return gap("provider-unavailable"); + if (failures.some((event) => event.data?.code === "WORKFLOW_STREAM_WRITE_FAILED")) + return gap("stream-write-failed"); + + const completedSteps = events.filter((event) => event.type === "step.completed").length; + const error = typeof evalArtifact?.error === "string" ? evalArtifact.error : undefined; + const timedOut = error !== undefined && TIMEOUT_ERROR.test(error); + if (completedSteps === 0 && timedOut) return gap("timeout-before-first-step"); + if (completedSteps === 0 && error !== undefined) return gap("error-before-first-step"); + if (timedOut) return { outcome: "timed_out" }; + if (isParkedOnInput(evalArtifact)) return { outcome: "parked" }; + return { outcome: "completed" }; +} + +function gap(reason) { + return { outcome: "gap", gap_reason: reason }; +} + +/** + * Provider-side and network failures are gaps. Other model-call 4xx responses + * (context too long, invalid request) and schema failures are real failures. + */ +export function isProviderFailure(event) { + const { code, details } = event.data ?? {}; + if (code !== "MODEL_CALL_FAILED") return false; + for (const status of [details?.statusCode, details?.upstreamStatusCode]) { + if (typeof status === "number" && (status === 429 || status >= 500)) return true; + } + if (details?.semanticErrorId === "network-request-failed") return true; + return NETWORK_FAILURE.test(JSON.stringify(details ?? {}) + (event.data?.message ?? "")); +} + +/** The primary session's last turn lifecycle event parks it on a person. */ +function isParkedOnInput(evalArtifact) { + const primaryId = evalArtifact?.result?.sessionId; + const primary = (evalArtifact?.result?.sessions ?? []).filter( + (session) => session.sessionId === primaryId, + ); + const lifecycle = primary + .flatMap((session) => session.events ?? []) + .filter((event) => event.type.startsWith("turn.")) + .sort((a, b) => Date.parse(a.meta?.at ?? "") - Date.parse(b.meta?.at ?? "")); + const last = lifecycle.at(-1); + return last?.type === "turn.waiting" && last.data?.on === "input"; +} diff --git a/scripts/eval-metrics/intervals.test.mjs b/scripts/eval-metrics/intervals.test.mjs new file mode 100644 index 0000000000..95a7be8e9f --- /dev/null +++ b/scripts/eval-metrics/intervals.test.mjs @@ -0,0 +1,101 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + coveredLength, + coveredLengthBetween, + firstDeltaInStep, + pairIntervals, + unionIntervals, +} from "./measurements/intervals.mjs"; + +const event = (type, id, second, data = {}) => ({ + type, + meta: { id, at: `2026-01-01T00:00:${String(second).padStart(2, "0")}Z` }, + data, +}); +const actionKeys = { + startKeys: (e) => e.data.actions?.map((action) => action.callId), + endKeys: (e) => [e.data.result?.callId], + subject: "action", +}; +const requested = (id, second, ...callIds) => + event("actions.requested", id, second, { actions: callIds.map((callId) => ({ callId })) }); +const result = (id, second, callId) => event("action.result", id, second, { result: { callId } }); + +test("pairs batched starts with out-of-order ends by key", () => { + const paired = pairIntervals( + [requested("batch", 1, "a", "b")], + [result("b-end", 5, "b"), result("a-end", 3, "a")], + actionKeys, + ); + assert.equal(paired.status, "ready"); + assert.deepEqual( + paired.intervals.map(({ key, startAt, endAt }) => [key, endAt - startAt]), + [ + ["a", 2000], + ["b", 4000], + ], + ); +}); + +test("partial, ambiguous, and malformed pairings are unavailable", () => { + for (const [starts, ends, reason] of [ + [[requested("r", 1, "a")], [], "action-incomplete"], + [[], [result("e", 2, "a")], "action-start-missing"], + [[requested("r", 1, "a"), requested("again", 2, "a")], [], "ambiguous-action"], + [[requested("r", 1, "a")], [result("e1", 2, "a"), result("e2", 3, "a")], "ambiguous-action"], + [[requested("r", 1)], [], "missing-action-identity"], + [[requested("r", 3, "a")], [result("e", 1, "a")], "negative-duration"], + [[requested("r", "xx", "a")], [result("e", 1, "a")], "missing-timestamp"], + ]) + assert.deepEqual(pairIntervals(starts, ends, actionKeys), { status: "unavailable", reason }); +}); + +test("repeated starts can resolve to the earliest announcement", () => { + const keys = { ...actionKeys, repeatedStarts: "earliest" }; + const paired = pairIntervals( + [requested("late", 4, "a"), requested("early", 2, "a")], + [result("e", 6, "a")], + keys, + ); + assert.equal(paired.intervals[0].start.meta.id, "early"); +}); + +test("unions overlapping intervals and measures coverage within and outside bounds", () => { + const spans = [ + { startAt: 0, endAt: 4 }, + { startAt: 2, endAt: 6 }, + { startAt: 6, endAt: 7 }, + { startAt: 10, endAt: 12 }, + ]; + assert.deepEqual(unionIntervals(spans), [ + { startAt: 0, endAt: 7 }, + { startAt: 10, endAt: 12 }, + ]); + assert.equal(coveredLength(spans), 9); + assert.equal(coveredLengthBetween(spans, { within: [{ startAt: 3, endAt: 11 }] }), 5); + assert.equal( + coveredLengthBetween(spans, { + within: [{ startAt: 3, endAt: 11 }], + excluding: [{ startAt: 5, endAt: 10.5 }], + }), + 2.5, + ); + assert.equal(coveredLength([]), 0); +}); + +test("finds the first text or reasoning delta of each step, ignoring other steps", () => { + const step = (type, id, second, stepIndex, turnId = "t") => + event(type, id, second, { turnId, stepIndex }); + const steps = firstDeltaInStep([ + step("step.started", "s0", 1, 0), + step("message.appended", "m-late", 4, 0), + step("reasoning.appended", "r-early", 2, 0), + step("step.started", "s1", 5, 1), + step("action.input.appended", "tool-delta", 6, 1), + step("message.appended", "other-turn", 3, 0, "other"), + ]); + assert.equal(steps.get("t/0").firstDelta.meta.id, "r-early"); + assert.equal(steps.get("t/1").firstDelta, undefined); + assert.equal(steps.has("other/0"), false); +}); diff --git a/scripts/eval-metrics/measurements.test.mjs b/scripts/eval-metrics/measurements.test.mjs new file mode 100644 index 0000000000..18a06c36d8 --- /dev/null +++ b/scripts/eval-metrics/measurements.test.mjs @@ -0,0 +1,192 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { captureSessions, selectEvents } from "./measurements/events.mjs"; +import { completedTurns, delegatedSessions, parentTurnStarts } from "./measurements/lifecycle.mjs"; +import { elapsedTime, totalToolCalls, totalTurnDuration } from "./measurements/metrics.mjs"; + +const event = (type, id, second, data = {}) => ({ + type, + meta: { id, at: `2026-01-01T00:00:${String(second).padStart(2, "0")}Z` }, + data, +}); +const turn = (id, start, end) => [ + event("turn.started", `${id}-start`, start, { turnId: id }), + event("turn.completed", `${id}-end`, end, { turnId: id }), +]; + +test("indexes captures, deduplicates identical events, and selects exact data fields", () => { + const start = event("turn.started", "start", 0, { turnId: "turn" }); + const later = event("turn.completed", "completed", 1, { turnId: "turn" }); + const capture = captureSessions([ + { sessionId: "session", events: [start, structuredClone(start)] }, + { sessionId: "session", events: [structuredClone(start), later] }, + ]); + assert.deepEqual(capture.events, [start, later]); + assert.deepEqual(capture.bySession.get("session"), [start, later]); + assert.deepEqual(selectEvents(capture.events, "turn.started", { turnId: "turn" }), [start]); + assert.deepEqual(selectEvents(capture.events, "turn.started", { turnId: "other" }), []); + assert.equal(capture.sessionId(start), "session"); + assert.deepEqual(capture.refs([start]), [{ sessionId: "session", eventId: "start" }]); + assert.equal(captureSessions(undefined), undefined); + assert.deepEqual(captureSessions([]).events, []); +}); + +test("rejects malformed or ambiguous capture identities", () => { + const start = event("turn.started", "start", 0); + const session = { sessionId: "session", events: [start] }; + for (const [sessions, message] of [ + [[{}], /Unsupported session capture/], + [[{ ...session, events: [{ type: "turn.started" }] }], /Unsupported event capture/], + [ + [session, { ...session, events: [{ ...start, data: { changed: true } }] }], + /Conflicting event identity/, + ], + [[session, { ...session, sessionId: "other" }], /Ambiguous session evidence/], + ]) + assert.throws(() => captureSessions(sessions), message); +}); + +test("measures spans, active turn time, and session-scoped tool calls independently", () => { + const capture = captureSessions([ + { + sessionId: "alice", + events: [ + ...turn("one", 1, 3), + ...turn("two", 6, 9), + event("actions.requested", "actions-one", 2, { + turnId: "one", + actions: [ + { kind: "tool-call", callId: "call" }, + { kind: "tool-call", callId: "call" }, + { kind: "text" }, + ], + }), + event("actions.requested", "actions-two", 7, { + turnId: "two", + actions: [{ kind: "tool-call", callId: "call" }], + }), + event("actions.requested", "unrelated", 8, { + turnId: "unselected", + actions: [{ kind: "tool-call" }], + }), + ], + }, + { + sessionId: "bob", + events: [ + ...turn("three", 2, 4), + event("actions.requested", "actions-three", 3, { + turnId: "three", + actions: [{ kind: "tool-call", callId: "call" }], + }), + ], + }, + ]); + const { turns } = completedTurns(capture, ["alice", "bob"]); + const span = elapsedTime( + capture, + turns.map((t) => t.start), + turns.map((t) => t.completed), + ); + assert.equal(span.value, 8000); + assert.equal(totalTurnDuration(capture, turns).value, 7000); + const calls = totalToolCalls(capture, turns); + assert.equal(calls.value, 2); + assert.equal(calls.evidence.length, 9); + assert(!calls.evidence.some((ref) => ref.eventId === "unrelated")); +}); + +test("turn matching rejects incomplete and ambiguous evidence", () => { + const [start, end] = turn("one", 1, 3); + for (const [events, reason] of [ + [[], "missing-turn"], + [[{ ...start, data: {} }, end], "missing-turn-identity"], + [[start], "turn-incomplete"], + [[start, end, { ...start, meta: { ...start.meta, id: "another-start" } }], "ambiguous-turn"], + [[start, end, { ...end, meta: { ...end.meta, id: "another-end" } }], "ambiguous-turn"], + [[start, end, event("turn.completed", "orphan", 4, { turnId: "other" })], "turn-start-missing"], + ]) { + const capture = captureSessions([{ sessionId: "session", events }]); + assert.deepEqual(completedTurns(capture, ["session"]), { status: "unavailable", reason }); + } +}); + +test("duration failures do not prevent independent tool counting", () => { + for (const [start, end, reason] of [ + [3, 1, "negative-duration"], + ["invalid", 3, "missing-timestamp"], + [1, "invalid", "missing-timestamp"], + ]) { + const capture = captureSessions([{ sessionId: "session", events: turn("one", start, end) }]); + const { turns } = completedTurns(capture, ["session"]); + assert.deepEqual(totalTurnDuration(capture, turns), { status: "unavailable", reason }); + assert.deepEqual(elapsedTime(capture, [turns[0].start], [turns[0].completed]), { + status: "unavailable", + reason, + }); + assert.equal(totalToolCalls(capture, turns).value, 0); + } +}); + +test("delegation helpers work with any subagent and allow repeated calls to the same child", () => { + const calls = ["first", "later"].map((callId) => + event("subagent.called", callId, 1, { + name: "editor", + childSessionId: "child", + turnId: "parent-turn", + callId, + }), + ); + const sessions = [ + { + sessionId: "parent", + events: [event("turn.started", "parent-start", 0, { turnId: "parent-turn" }), ...calls], + }, + { + sessionId: "child", + events: [ + event("session.started", "child-session", 1, { + invocation: { + kind: "subagent", + name: "editor", + parentCallId: "first", + parentSessionId: "parent", + parentTurnId: "parent-turn", + }, + }), + ...turn("child-turn", 2, 3), + ], + }, + ]; + const capture = captureSessions(sessions); + assert.deepEqual(delegatedSessions(capture, calls), { status: "ready", sessionIds: ["child"] }); + assert.equal(parentTurnStarts(capture, calls).events.length, 2); + sessions[1].events[0].data.invocation.parentCallId = "later"; + assert.equal( + delegatedSessions(captureSessions(sessions), calls).reason, + "child-invocation-mismatch", + ); + sessions[1].events[0].data = { runtime: { agentId: "editor" } }; + assert.deepEqual(delegatedSessions(captureSessions(sessions), calls), { + status: "ready", + sessionIds: ["child"], + }); + sessions[1].events[0].data.runtime.agentId = "wrong-agent"; + assert.equal( + delegatedSessions(captureSessions(sessions), calls).reason, + "child-invocation-mismatch", + ); + sessions[1].events[0].data.runtime.agentId = "editor"; + sessions[0].events.push( + event("subagent.called", "unrelated", 1, { + name: "other-agent", + childSessionId: "child", + turnId: "parent-turn", + callId: "other-call", + }), + ); + assert.equal( + delegatedSessions(captureSessions(sessions), sessions[0].events.slice(1)).reason, + "ambiguous-child-parent", + ); +}); diff --git a/scripts/eval-metrics/measurements/events.mjs b/scripts/eval-metrics/measurements/events.mjs new file mode 100644 index 0000000000..d79c62c4db --- /dev/null +++ b/scripts/eval-metrics/measurements/events.mjs @@ -0,0 +1,78 @@ +/** + * @typedef {{ sessionId: string, eventId: string }} EventReference + * @typedef {{ status: "measured", value: number, evidence?: readonly EventReference[] } + * | { status: "unavailable" | "not-applicable", reason: string }} Measurement + */ + +/** @param {string} reason @returns {{ status: "unavailable", reason: string }} */ +export function unavailable(reason) { + return { status: "unavailable", reason }; +} + +/** @param {string} reason @returns {{ status: "not-applicable", reason: string }} */ +export function notApplicable(reason) { + return { status: "not-applicable", reason }; +} + +/** Merge repeated session captures and validate event identities before selecting measurement evidence. */ +export function captureSessions(sessions) { + if (!Array.isArray(sessions)) return undefined; + + const bySession = new Map(); + const eventSessions = new Map(); + const identities = new Map(); + for (const session of sessions) { + if (typeof session?.sessionId !== "string" || !Array.isArray(session.events)) + throw new Error("Unsupported session capture shape."); + const events = bySession.get(session.sessionId) ?? []; + for (const event of session.events) { + if (!event || typeof event.type !== "string" || typeof event.meta?.id !== "string") + throw new Error("Unsupported event capture shape or missing event identity."); + const id = event.meta.id; + const serialized = JSON.stringify(event); + const earlier = identities.get(id); + if (earlier !== undefined) { + if (earlier !== serialized) throw new Error(`Conflicting event identity: ${id}`); + if (eventSessions.get(id) !== session.sessionId) + throw new Error(`Ambiguous session evidence for event identity: ${id}`); + continue; + } + identities.set(id, serialized); + eventSessions.set(id, session.sessionId); + events.push(event); + } + bySession.set(session.sessionId, events); + } + + return { + bySession, + events: [...bySession.values()].flat(), + sessionId: (event) => eventSessions.get(event.meta.id), + refs: (events) => + events.map((event) => ({ + sessionId: eventSessions.get(event.meta.id), + eventId: event.meta.id, + })), + }; +} + +/** Match an event type and exact data fields, preserving capture order. */ +export function selectEvents(events, type, data = {}) { + return events.filter( + (event) => + event.type === type && + Object.entries(data).every(([key, value]) => event.data?.[key] === value), + ); +} + +export function timestamp(event) { + const value = Date.parse(event?.meta?.at ?? ""); + return Number.isFinite(value) ? value : undefined; +} + +export function compareEventTime(left, right) { + const leftAt = timestamp(left); + const rightAt = timestamp(right); + if (leftAt === undefined) return rightAt === undefined ? 0 : 1; + return rightAt === undefined ? -1 : leftAt - rightAt; +} diff --git a/scripts/eval-metrics/measurements/intervals.mjs b/scripts/eval-metrics/measurements/intervals.mjs new file mode 100644 index 0000000000..afa726dd0f --- /dev/null +++ b/scripts/eval-metrics/measurements/intervals.mjs @@ -0,0 +1,158 @@ +import { selectEvents, timestamp, unavailable } from "./events.mjs"; + +/** + * @typedef {{ key: string, start: object, end: object, startAt: number, endAt: number }} Interval + * @typedef {{ status: "ready", intervals: Interval[] } | { status: "unavailable", reason: string }} Intervals + */ + +/** + * Pair start and end events by key, independent of capture order. One event + * may open or close several keys (an `actions.requested` batch, an + * `input.resolved` batch). Every start needs exactly one end and vice versa; + * `repeatedStarts: "earliest"` tolerates re-announced starts such as repeated + * approval candidates for one request. + * + * @param {object[]} starts + * @param {object[]} ends + * @param {{ + * startKeys: (event: object) => unknown[], + * endKeys: (event: object) => unknown[], + * subject: string, + * repeatedStarts?: "ambiguous" | "earliest", + * closeOpen?: (start: object) => object | undefined, + * }} options `closeOpen` may supply an end for a start that has none, such as + * the terminal event of a cancelled turn. + * @returns {Intervals} + */ +export function pairIntervals( + starts, + ends, + { startKeys, endKeys, subject, repeatedStarts, closeOpen }, +) { + const opened = indexByKey(starts, startKeys, repeatedStarts === "earliest"); + if (opened === undefined) return unavailable(`missing-${subject}-identity`); + if (opened === "ambiguous") return unavailable(`ambiguous-${subject}`); + const closed = indexByKey(ends, endKeys, false); + if (closed === undefined) return unavailable(`missing-${subject}-identity`); + if (closed === "ambiguous") return unavailable(`ambiguous-${subject}`); + + const intervals = []; + for (const [key, start] of opened) { + const end = closed.get(key) ?? closeOpen?.(start); + if (end === undefined) return unavailable(`${subject}-incomplete`); + const startAt = timestamp(start); + const endAt = timestamp(end); + if (startAt === undefined || endAt === undefined) return unavailable("missing-timestamp"); + if (endAt < startAt) return unavailable("negative-duration"); + intervals.push({ key, start, end, startAt, endAt }); + } + if ([...closed.keys()].some((key) => !opened.has(key))) + return unavailable(`${subject}-start-missing`); + return { status: "ready", intervals }; +} + +function indexByKey(events, keysOf, keepEarliest) { + const byKey = new Map(); + for (const event of events) { + const keys = keysOf(event); + if (!Array.isArray(keys) || keys.length === 0) return undefined; + for (const key of keys) { + if (typeof key !== "string" || key.length === 0) return undefined; + const earlier = byKey.get(key); + if (earlier === undefined) byKey.set(key, event); + else if (!keepEarliest) return "ambiguous"; + else if ((timestamp(event) ?? Infinity) < (timestamp(earlier) ?? Infinity)) + byKey.set(key, event); + } + } + return byKey; +} + +/** + * Merge overlapping or touching intervals into a sorted disjoint list. + * + * @param {readonly { startAt: number, endAt: number }[]} intervals + * @returns {{ startAt: number, endAt: number }[]} + */ +export function unionIntervals(intervals) { + const sorted = [...intervals].sort((a, b) => a.startAt - b.startAt); + const merged = []; + for (const { startAt, endAt } of sorted) { + const last = merged.at(-1); + if (last !== undefined && startAt <= last.endAt) last.endAt = Math.max(last.endAt, endAt); + else merged.push({ startAt, endAt }); + } + return merged; +} + +/** Total covered length of possibly overlapping intervals. */ +export function coveredLength(intervals) { + return unionIntervals(intervals).reduce( + (total, { startAt, endAt }) => total + endAt - startAt, + 0, + ); +} + +/** + * Covered length of `intervals` that lies inside `within` and outside `excluding`. + * + * @param {readonly { startAt: number, endAt: number }[]} intervals + * @param {{ within?: readonly { startAt: number, endAt: number }[], excluding?: readonly { startAt: number, endAt: number }[] }} [bounds] + */ +export function coveredLengthBetween(intervals, { within, excluding = [] } = {}) { + let covered = unionIntervals(intervals); + if (within !== undefined) covered = intersect(covered, unionIntervals(within)); + const removed = intersect(covered, unionIntervals(excluding)); + return lengthOf(covered) - lengthOf(removed); +} + +function intersect(left, right) { + const result = []; + let i = 0; + let j = 0; + while (i < left.length && j < right.length) { + const startAt = Math.max(left[i].startAt, right[j].startAt); + const endAt = Math.min(left[i].endAt, right[j].endAt); + if (startAt < endAt) result.push({ startAt, endAt }); + if (left[i].endAt < right[j].endAt) i += 1; + else j += 1; + } + return result; +} + +function lengthOf(disjoint) { + return disjoint.reduce((total, { startAt, endAt }) => total + endAt - startAt, 0); +} + +/** + * For each started model step, find the first streamed text or reasoning + * delta of that step. Steps that stream no delta (e.g. tool-call only) map to + * `undefined`; callers decide whether that is not-applicable. + * + * @param {object[]} events one session's events + * @returns {Map} keyed by `turnId/stepIndex` + */ +export function firstDeltaInStep(events) { + const steps = new Map(); + for (const start of selectEvents(events, "step.started")) { + const key = stepKey(start); + if (key !== undefined) steps.set(key, { start, firstDelta: undefined }); + } + for (const event of events) { + if (event.type !== "message.appended" && event.type !== "reasoning.appended") continue; + const step = steps.get(stepKey(event)); + if (step === undefined) continue; + const at = timestamp(event); + if (at === undefined) continue; + if (step.firstDelta === undefined || at < timestamp(step.firstDelta)) step.firstDelta = event; + } + return steps; +} + +/** Identity of one model step within a session. */ +export function stepKey(event) { + const { turnId, stepIndex } = event.data ?? {}; + return typeof turnId === "string" && Number.isInteger(stepIndex) + ? `${turnId}/${stepIndex}` + : undefined; +} diff --git a/scripts/eval-metrics/measurements/lifecycle.mjs b/scripts/eval-metrics/measurements/lifecycle.mjs new file mode 100644 index 0000000000..97eb0fe876 --- /dev/null +++ b/scripts/eval-metrics/measurements/lifecycle.mjs @@ -0,0 +1,99 @@ +import { selectEvents, unavailable } from "./events.mjs"; + +/** Resolve selected calls to child sessions and verify their initial invocation. */ +export function delegatedSessions(capture, calls) { + if (calls.length === 0) return unavailable("missing-delegation"); + const children = new Map(); + for (const called of calls) { + const parentSessionId = capture.sessionId(called); + const { childSessionId, turnId: parentTurnId, callId } = called.data ?? {}; + if (![childSessionId, parentTurnId, callId].every((value) => typeof value === "string")) + return unavailable("missing-delegation-identities"); + if (childSessionId === parentSessionId) return unavailable("child-session-reused"); + if (!capture.bySession.has(childSessionId)) return unavailable("missing-child-capture"); + + const child = children.get(childSessionId) ?? { + parentSessionId, + name: called.data.name, + calls: [], + }; + if (child.parentSessionId !== parentSessionId || child.name !== called.data.name) + return unavailable("ambiguous-child-parent"); + child.calls.push({ callId, parentTurnId }); + children.set(childSessionId, child); + } + + for (const [sessionId, child] of children) { + const invocations = selectEvents(capture.bySession.get(sessionId), "session.started"); + const invocation = invocations[0]?.data?.invocation; + const initialCall = child.calls[0]; + if (invocations.length !== 1) return unavailable("child-invocation-mismatch"); + if (invocation === undefined) { + // Some captures omit invocation metadata; the parent's childSessionId is + // the link, and the child's runtime identity must agree with the selected agent. + if (invocations[0].data?.runtime?.agentId !== child.name) + return unavailable("child-invocation-mismatch"); + } else if ( + invocation.kind !== "subagent" || + invocation.parentCallId !== initialCall.callId || + invocation.parentSessionId !== child.parentSessionId || + invocation.parentTurnId !== initialCall.parentTurnId || + invocation.name !== child.name + ) { + return unavailable("child-invocation-mismatch"); + } + } + return { status: /** @type {const} */ ("ready"), sessionIds: [...children.keys()] }; +} + +/** Require a unique parent turn start for each selected delegation. */ +export function parentTurnStarts(capture, calls) { + const starts = []; + for (const called of calls) { + const matches = selectEvents(capture.bySession.get(capture.sessionId(called)), "turn.started", { + turnId: called.data.turnId, + }); + if (matches.length !== 1) + return unavailable( + matches.length > 1 ? "ambiguous-parent-turn-start" : "missing-parent-turn-start", + ); + starts.push(matches[0]); + } + return { status: /** @type {const} */ ("ready"), events: starts }; +} + +/** + * Pair every turn in selected sessions with its terminal event; partial or + * ambiguous captures are unavailable. `terminalTypes` widens the accepted + * terminals, e.g. to include failed and cancelled turns. + */ +export function completedTurns( + capture, + sessionIds, + { subject = "turn", terminalTypes = ["turn.completed"] } = {}, +) { + const terminals = (events, data) => + terminalTypes.flatMap((type) => selectEvents(events, type, data)); + const turns = []; + for (const sessionId of sessionIds) { + const events = capture.bySession.get(sessionId); + const starts = selectEvents(events, "turn.started"); + if (starts.length === 0) return unavailable(`missing-${subject}`); + const completedIds = new Set(); + for (const start of starts) { + const turnId = start.data?.turnId; + if (typeof turnId !== "string") return unavailable(`missing-${subject}-identity`); + if (completedIds.has(turnId)) return unavailable(`ambiguous-${subject}`); + const completions = terminals(events, { turnId }); + if (completions.length !== 1) + return unavailable( + completions.length > 1 ? `ambiguous-${subject}` : `${subject}-incomplete`, + ); + completedIds.add(turnId); + turns.push({ sessionId, start, completed: completions[0], events }); + } + if (terminals(events).some((event) => !completedIds.has(event.data?.turnId))) + return unavailable(`${subject}-start-missing`); + } + return { status: /** @type {const} */ ("ready"), turns }; +} diff --git a/scripts/eval-metrics/measurements/metrics.mjs b/scripts/eval-metrics/measurements/metrics.mjs new file mode 100644 index 0000000000..cbb24e772f --- /dev/null +++ b/scripts/eval-metrics/measurements/metrics.mjs @@ -0,0 +1,101 @@ +import { compareEventTime, selectEvents, timestamp, unavailable } from "./events.mjs"; + +/** @returns {import('./events.mjs').Measurement} */ +function measured(capture, value, events) { + return { status: "measured", value, evidence: capture.refs(events) }; +} + +/** Span from the earliest start to the latest completion, including gaps between turns. */ +export function elapsedTime( + capture, + starts, + completions, + { evidence = [], missingReason = "missing-timestamp" } = {}, +) { + const sortedStarts = [...starts].sort(compareEventTime); + const sortedCompletions = [...completions].sort(compareEventTime); + const startAt = timestamp(sortedStarts[0]); + const endAt = timestamp(sortedCompletions.at(-1)); + if (startAt === undefined || endAt === undefined) return unavailable(missingReason); + if (endAt < startAt) return unavailable("negative-duration"); + return measured(capture, endAt - startAt, [...sortedStarts, ...evidence, ...sortedCompletions]); +} + +/** Sum paired turn durations, excluding gaps and counting overlapping turns separately. */ +export function totalTurnDuration(capture, turns, { missingReason = "missing-timestamp" } = {}) { + let total = 0; + const evidence = []; + for (const { start, completed } of turns) { + const startAt = timestamp(start); + const endAt = timestamp(completed); + if (startAt === undefined || endAt === undefined) return unavailable(missingReason); + if (endAt < startAt) return unavailable("negative-duration"); + total += endAt - startAt; + evidence.push(start, completed); + } + return measured(capture, total, evidence); +} + +/** Sum recorded model costs only when every selected model step has complete evidence. */ +export function totalModelCost(capture, turns) { + const selectedTurns = new Map(); + for (const { sessionId, start } of turns) { + const ids = selectedTurns.get(sessionId) ?? new Set(); + ids.add(start.data.turnId); + selectedTurns.set(sessionId, ids); + } + const starts = new Map(); + const completions = new Map(); + const observedTurns = new Set(); + for (const [sessionId, turnIds] of selectedTurns) { + for (const event of capture.bySession.get(sessionId)) { + if (!["step.started", "step.completed", "step.failed"].includes(event.type)) continue; + const { turnId, stepIndex } = event.data ?? {}; + if (!turnIds.has(turnId) || !Number.isInteger(stepIndex) || stepIndex < 0) + return unavailable("missing-model-step-identity"); + if (event.type === "step.failed") return unavailable("failed-model-step"); + const key = JSON.stringify([sessionId, turnId, stepIndex]); + const indexed = event.type === "step.started" ? starts : completions; + if (indexed.has(key)) return unavailable("ambiguous-model-step"); + indexed.set(key, event); + observedTurns.add(JSON.stringify([sessionId, turnId])); + } + } + for (const [sessionId, turnIds] of selectedTurns) { + for (const turnId of turnIds) { + if (!observedTurns.has(JSON.stringify([sessionId, turnId]))) + return unavailable("missing-model-steps"); + } + } + if (starts.size === 0 && completions.size === 0) return unavailable("missing-model-steps"); + if (starts.size !== completions.size || [...starts.keys()].some((key) => !completions.has(key))) + return unavailable("incomplete-model-steps"); + let total = 0; + for (const event of completions.values()) { + const cost = event.data.usage?.costUsd; + if (typeof cost !== "number" || !Number.isFinite(cost) || cost < 0) + return unavailable("missing-or-invalid-model-cost"); + total += cost; + } + if (!Number.isFinite(total)) return unavailable("invalid-total-model-cost"); + return measured(capture, total, [...starts.values(), ...completions.values()]); +} + +/** Count distinct requested tool calls per session within the selected turns. */ +export function totalToolCalls(capture, turns) { + const ids = new Set(); + const evidence = turns.flatMap(({ start, completed }) => [start, completed]); + for (const { sessionId, start, events } of turns) { + for (const event of selectEvents(events, "actions.requested", { turnId: start.data.turnId })) { + if (!Array.isArray(event.data.actions)) return unavailable("missing-tool-call-identity"); + evidence.push(event); + for (const action of event.data.actions) { + if (action?.kind !== "tool-call") continue; + if (typeof action.callId !== "string" || !action.callId) + return unavailable("missing-tool-call-identity"); + ids.add(`${sessionId}\0${action.callId}`); + } + } + } + return measured(capture, ids.size, evidence); +} diff --git a/scripts/eval-metrics/standard.mjs b/scripts/eval-metrics/standard.mjs new file mode 100644 index 0000000000..4020452108 --- /dev/null +++ b/scripts/eval-metrics/standard.mjs @@ -0,0 +1,581 @@ +// Standard per-attempt metric set derived from one eval detail artifact +// (`.eve/evals//evals/.json`). See +// research/model-benchmark-tracking.md for the metric table. +// +// Scope: latency, attribution, cost, tokens, and efficiency counts read the +// eval's root sessions (sessions the eval opened, not delegated children); +// delegated spend reaches them through rolled-up `usage`. Reliability counts +// read every captured session, because a failure anywhere matters. +// +// Attribution partitions root turn time with a fixed precedence, so the parts +// add up to the turn total: human wait, then task (delegated work, including +// time a `task_wait` call blocks on it), tool (any other requested action), +// compaction, model step, and the remainder is framework time. In-process +// tools run inside their model step, so precedence keeps model ms to model work. +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + captureSessions, + notApplicable, + selectEvents, + timestamp, + unavailable, +} from "./measurements/events.mjs"; +import { + coveredLengthBetween, + firstDeltaInStep, + pairIntervals, + stepKey, +} from "./measurements/intervals.mjs"; +import { completedTurns } from "./measurements/lifecycle.mjs"; + +/** Bump whenever a definition below changes meaning. */ +export const METRICS_VERSION = 1; + +/** Every metric in the standard set, with its unit and better direction. */ +export const STANDARD_METRICS = Object.freeze({ + "latency.eval_wall_ms": { unit: "ms", direction: "lower" }, + "latency.turn_ms": { unit: "ms", direction: "lower" }, + "latency.turn_ms_mean": { unit: "ms", direction: "lower" }, + "latency.ttft_ms_p50": { unit: "ms", direction: "lower" }, + "latency.output_tokens_per_s": { unit: "tokens/s", direction: "higher" }, + "attribution.model_ms": { unit: "ms", direction: "lower" }, + "attribution.tool_ms": { unit: "ms", direction: "lower" }, + "attribution.task_ms": { unit: "ms", direction: "lower" }, + "attribution.human_wait_ms": { unit: "ms", direction: "neutral" }, + "attribution.compaction_ms": { unit: "ms", direction: "lower" }, + "attribution.framework_ms": { unit: "ms", direction: "lower" }, + "cost.total_usd": { unit: "usd", direction: "lower" }, + "cost.primary_usd": { unit: "usd", direction: "lower" }, + "cost.subagent_usd": { unit: "usd", direction: "lower" }, + "tokens.input": { unit: "tokens", direction: "lower" }, + "tokens.output": { unit: "tokens", direction: "lower" }, + "tokens.cache_read": { unit: "tokens", direction: "neutral" }, + "tokens.cache_write": { unit: "tokens", direction: "neutral" }, + "tokens.cache_hit_ratio": { unit: "ratio", direction: "higher" }, + "tokens.peak_context": { unit: "tokens", direction: "lower" }, + "counts.turns": { unit: "count", direction: "neutral" }, + "counts.steps": { unit: "count", direction: "lower" }, + "counts.steps_per_turn": { unit: "ratio", direction: "lower" }, + "counts.tool_calls": { unit: "count", direction: "neutral" }, + "counts.tool_calls_per_step": { unit: "ratio", direction: "neutral" }, + "counts.subagent_fan_out": { unit: "count", direction: "neutral" }, + "counts.subagent_depth": { unit: "count", direction: "neutral" }, + "counts.compactions": { unit: "count", direction: "lower" }, + "counts.context_clears": { unit: "count", direction: "neutral" }, + "reliability.step_failures": { unit: "count", direction: "lower" }, + "reliability.turn_failures": { unit: "count", direction: "lower" }, + "reliability.session_failures": { unit: "count", direction: "lower" }, + "reliability.tool_errors": { unit: "count", direction: "lower" }, + "reliability.tool_error_rate": { unit: "ratio", direction: "lower" }, + "reliability.truncations": { unit: "count", direction: "lower" }, +}); + +const TURN_TERMINALS = ["turn.completed", "turn.failed", "turn.cancelled"]; +const USAGE_EVENTS = ["session.waiting", "turn.waiting", "session.completed", "session.failed"]; +const FAILURE_EVENTS = ["step.failed", "turn.failed", "session.failed"]; +const TOOL_ACTION_KINDS = new Set(["tool-call", "workflow-tool-call"]); + +/** + * @param {object} evalArtifact parsed eval detail JSON + * @returns {{ + * metrics_version: number, + * metrics: Record, + * failures: { type: string, code: string, count: number }[], + * }} + */ +export function deriveAttemptMetrics(evalArtifact) { + const sessions = evalArtifact?.result?.sessions; + let capture; + try { + capture = captureSessions( + Array.isArray(sessions) + ? sessions.filter((session) => typeof session?.sessionId === "string") + : undefined, + ); + } catch { + return everyMetric(unavailable("invalid-session-capture")); + } + if (capture === undefined) return everyMetric(unavailable("missing-session-capture")); + if (capture.bySession.size === 0) return everyMetric(notApplicable("no-sessions")); + + const rootIds = rootSessionIds(capture); + if (rootIds.length === 0) return everyMetric(unavailable("missing-root-session")); + const roots = rootIds.map((sessionId) => ({ + events: capture.bySession.get(sessionId), + timeline: sessionTimeline(capture, sessionId), + })); + const allEvents = capture.events; + + const metrics = { + ...latencyAndAttribution(evalArtifact, roots), + ...streaming(roots), + ...costAndTokens(roots), + ...efficiency(capture, roots, rootIds), + ...reliability(allEvents), + }; + return { metrics_version: METRICS_VERSION, metrics, failures: failureCodes(allEvents) }; +} + +function everyMetric(measurement) { + return { + metrics_version: METRICS_VERSION, + metrics: Object.fromEntries(Object.keys(STANDARD_METRICS).map((key) => [key, measurement])), + failures: [], + }; +} + +function measured(value) { + return { status: "measured", value }; +} + +/** Sessions the eval opened itself: neither linked by `agent.started` nor invoked as a subagent. */ +function rootSessionIds(capture) { + const children = new Set( + selectEvents(capture.events, "agent.started").map((event) => event.data?.sessionId), + ); + for (const [sessionId, events] of capture.bySession) { + if ( + selectEvents(events, "session.started").some((e) => e.data?.invocation?.kind === "subagent") + ) + children.add(sessionId); + } + return [...capture.bySession.keys()].filter((sessionId) => !children.has(sessionId)); +} + +/** Pair every interval family of one session, clipping nothing yet. */ +function sessionTimeline(capture, sessionId) { + const events = capture.bySession.get(sessionId); + const turns = completedTurns(capture, [sessionId], { terminalTypes: TURN_TERMINALS }); + const terminals = new Map( + turns.status === "ready" ? turns.turns.map((t) => [t.start.data.turnId, t.completed]) : [], + ); + // Work a turn abandons (a cancelled turn, a step cut off by a session + // limit) ends with that turn. + const closeOpen = (start) => terminals.get(start.data?.turnId); + + const steps = pairIntervals( + selectEvents(events, "step.started"), + [...selectEvents(events, "step.completed"), ...selectEvents(events, "step.failed")], + { startKeys: (e) => [stepKey(e)], endKeys: (e) => [stepKey(e)], subject: "step", closeOpen }, + ); + const actions = pairIntervals( + selectEvents(events, "actions.requested"), + selectEvents(events, "action.result"), + { + startKeys: (e) => + Array.isArray(e.data?.actions) ? e.data.actions.map((a) => a?.callId) : [], + endKeys: (e) => [e.data?.result?.callId], + subject: "action", + repeatedStarts: "earliest", + closeOpen, + }, + ); + const taskKey = (e) => [taskIdentity(e)]; + const tasks = pairIntervals( + selectEvents(events, "task.started"), + selectEvents(events, "task.settled"), + { startKeys: taskKey, endKeys: taskKey, subject: "task", closeOpen }, + ); + const compactions = pairIntervals( + selectEvents(events, "compaction.requested"), + selectEvents(events, "compaction.completed"), + { + startKeys: (e) => [stepKey(e)], + endKeys: (e) => [stepKey(e)], + subject: "compaction", + closeOpen, + }, + ); + return { + events, + turns: + turns.status === "ready" + ? toIntervals(turns.turns.map((t) => ({ start: t.start, end: t.completed }))) + : turns, + steps, + actions, + tasks, + compactions, + human: humanWait(events, closeOpen), + }; +} + +function taskIdentity(event) { + const { taskId, callId } = event.data ?? {}; + return typeof taskId === "string" && typeof callId === "string" + ? `${taskId}/${callId}` + : undefined; +} + +function toIntervals(pairs) { + const intervals = []; + for (const { start, end } of pairs) { + const startAt = timestamp(start); + const endAt = timestamp(end); + if (startAt === undefined || endAt === undefined) return unavailable("missing-timestamp"); + if (endAt < startAt) return unavailable("negative-duration"); + intervals.push({ start, end, startAt, endAt }); + } + return { status: "ready", intervals }; +} + +/** Input requests, responder-bound approvals, and sign-ins, from request to resolution. */ +function humanWait(events, closeOpen) { + const requestIds = (list) => (Array.isArray(list) ? list.map((entry) => entry?.requestId) : []); + const settledResolutions = (event) => + (event.data?.resolutions ?? []).filter((resolution) => resolution?.outcome !== "invalid"); + const input = pairIntervals( + selectEvents(events, "input.requested"), + selectEvents(events, "input.resolved").filter((e) => settledResolutions(e).length > 0), + { + startKeys: (e) => requestIds(e.data?.requests), + endKeys: (e) => requestIds(settledResolutions(e)), + subject: "input", + closeOpen, + }, + ); + + // `approval.settled` also closes approvals delivered only through + // `input.requested`; only responder-bound candidates open an interval here. + const candidates = selectEvents(events, "approval.candidate", { outcome: "pending" }); + const candidateIds = new Set(candidates.map((e) => e.data?.requestId)); + const approval = pairIntervals( + candidates, + selectEvents(events, "approval.settled").filter((e) => candidateIds.has(e.data?.requestId)), + { + startKeys: (e) => [e.data?.requestId], + endKeys: (e) => [e.data?.requestId], + subject: "approval", + repeatedStarts: "earliest", + closeOpen, + }, + ); + + // `attemptId` is not present on every `authorization.required`, so key by + // the turn and connection name, which both events always carry. + const authorizationKey = (e) => + typeof e.data?.turnId === "string" && typeof e.data?.name === "string" + ? [`${e.data.turnId}/${e.data.name}`] + : []; + const authorization = pairIntervals( + selectEvents(events, "authorization.required"), + selectEvents(events, "authorization.completed"), + { + startKeys: authorizationKey, + endKeys: authorizationKey, + subject: "authorization", + repeatedStarts: "earliest", + closeOpen, + }, + ); + + for (const family of [input, approval, authorization]) + if (family.status !== "ready") return family; + return { + status: "ready", + intervals: [...input.intervals, ...approval.intervals, ...authorization.intervals], + }; +} + +function latencyAndAttribution(evalArtifact, roots) { + const parts = { + turn: [], + human: [], + tool: [], + task: [], + compaction: [], + model: [], + framework: [], + }; + let failure; + let turnCount = 0; + const humanIntervals = []; + for (const { timeline } of roots) { + const families = ["turns", "human", "actions", "tasks", "compactions", "steps"]; + const broken = families.map((name) => timeline[name]).find((f) => f.status !== "ready"); + if (broken !== undefined) { + failure ??= broken; + continue; + } + const T = timeline.turns.intervals; + const H = timeline.human.intervals; + const A = timeline.actions.intervals; + const K = timeline.tasks.intervals; + const C = timeline.compactions.intervals; + const M = timeline.steps.intervals; + const total = coveredLengthBetween(T); + const human = coveredLengthBetween(H, { within: T }); + parts.turn.push(total - human); + parts.human.push(human); + parts.task.push(coveredLengthBetween(K, { within: T, excluding: H })); + parts.tool.push(coveredLengthBetween(A, { within: T, excluding: [...H, ...K] })); + parts.compaction.push(coveredLengthBetween(C, { within: T, excluding: [...H, ...A, ...K] })); + parts.model.push(coveredLengthBetween(M, { within: T, excluding: [...H, ...A, ...K, ...C] })); + parts.framework.push( + total - coveredLengthBetween([...H, ...A, ...K, ...C, ...M], { within: T }), + ); + turnCount += T.length; + humanIntervals.push(...H); + } + + const sum = (values) => (failure ? failure : measured(values.reduce((a, b) => a + b, 0))); + return { + "latency.eval_wall_ms": failure ?? evalWall(evalArtifact, humanIntervals), + "latency.turn_ms": sum(parts.turn), + "latency.turn_ms_mean": + failure ?? + (turnCount === 0 + ? notApplicable("no-turns") + : measured(parts.turn.reduce((a, b) => a + b, 0) / turnCount)), + "attribution.model_ms": sum(parts.model), + "attribution.tool_ms": sum(parts.tool), + "attribution.task_ms": sum(parts.task), + "attribution.human_wait_ms": sum(parts.human), + "attribution.compaction_ms": sum(parts.compaction), + "attribution.framework_ms": sum(parts.framework), + }; +} + +function evalWall(evalArtifact, humanIntervals) { + const startAt = Date.parse(evalArtifact?.startedAt ?? ""); + const endAt = Date.parse(evalArtifact?.completedAt ?? ""); + if (!Number.isFinite(startAt) || !Number.isFinite(endAt)) + return unavailable("missing-eval-timing"); + if (endAt < startAt) return unavailable("negative-duration"); + const human = coveredLengthBetween(humanIntervals, { within: [{ startAt, endAt }] }); + return measured(endAt - startAt - human); +} + +function streaming(roots) { + const ttfts = []; + let tokens = 0; + let streamMs = 0; + let missingTokens = false; + for (const { events } of roots) { + const completions = new Map( + selectEvents(events, "step.completed").map((event) => [stepKey(event), event]), + ); + for (const [key, { start, firstDelta }] of firstDeltaInStep(events)) { + const startAt = timestamp(start); + if (firstDelta === undefined || startAt === undefined) continue; + const deltaAt = timestamp(firstDelta); + ttfts.push(deltaAt - startAt); + const completed = completions.get(key); + if (completed === undefined) continue; + const outputTokens = completed.data?.usage?.outputTokens; + if (typeof outputTokens !== "number") missingTokens = true; + else tokens += outputTokens; + streamMs += (timestamp(completed) ?? deltaAt) - deltaAt; + } + } + if (ttfts.length === 0) { + const none = notApplicable("no-streamed-steps"); + return { "latency.ttft_ms_p50": none, "latency.output_tokens_per_s": none }; + } + return { + "latency.ttft_ms_p50": measured(median(ttfts)), + "latency.output_tokens_per_s": missingTokens + ? unavailable("missing-output-tokens") + : streamMs <= 0 + ? unavailable("zero-stream-duration") + : measured((tokens / streamMs) * 1000), + }; +} + +function median(values) { + const sorted = [...values].sort((a, b) => a - b); + const middle = Math.floor(sorted.length / 2); + return sorted.length % 2 === 1 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2; +} + +function costAndTokens(roots) { + const steps = roots.flatMap(({ events }) => selectEvents(events, "step.completed")); + const rollups = roots.map(({ events }) => latestUsage(events)); + + let primary; + if (steps.length === 0) primary = notApplicable("no-model-steps"); + else if (steps.some((step) => !isAmount(step.data?.usage?.costUsd))) + primary = unavailable("missing-step-cost"); + else primary = measured(steps.reduce((total, step) => total + step.data.usage.costUsd, 0)); + + const rolled = (field, reason) => + rollups.some((usage) => !isAmount(usage?.[field])) + ? unavailable(reason) + : measured(rollups.reduce((total, usage) => total + usage[field], 0)); + const total = rolled("costUsd", "missing-rolled-up-cost"); + + let subagent; + if (total.status !== "measured") subagent = total; + else if (primary.status === "not-applicable") subagent = total; + else if (primary.status !== "measured") subagent = primary; + else { + const difference = total.value - primary.value; + // Float sums of identical costs can differ in the last bits. + subagent = + difference < -1e-9 + ? unavailable("inconsistent-cost-rollup") + : measured(Math.max(0, difference)); + } + + const input = rolled("inputTokens", "missing-rolled-up-usage"); + const cacheRead = rolled("cacheReadTokens", "missing-rolled-up-usage"); + let cacheHitRatio; + if (input.status !== "measured") cacheHitRatio = input; + else if (cacheRead.status !== "measured") cacheHitRatio = cacheRead; + else if (input.value === 0) cacheHitRatio = notApplicable("no-input-tokens"); + else cacheHitRatio = measured(cacheRead.value / input.value); + + let peak; + if (steps.length === 0) peak = notApplicable("no-model-steps"); + else if (steps.some((step) => !isAmount(step.data?.usage?.inputTokens))) + peak = unavailable("missing-step-input-tokens"); + else peak = measured(Math.max(...steps.map((step) => step.data.usage.inputTokens))); + + return { + "cost.total_usd": total, + "cost.primary_usd": primary, + "cost.subagent_usd": subagent, + "tokens.input": input, + "tokens.output": rolled("outputTokens", "missing-rolled-up-usage"), + "tokens.cache_read": cacheRead, + "tokens.cache_write": rolled("cacheWriteTokens", "missing-rolled-up-usage"), + "tokens.cache_hit_ratio": cacheHitRatio, + "tokens.peak_context": peak, + }; +} + +function isAmount(value) { + return typeof value === "number" && Number.isFinite(value) && value >= 0; +} + +/** The session's latest rolled-up usage, which includes delegated spend. */ +function latestUsage(events) { + let latest; + for (const event of events) { + if (!USAGE_EVENTS.includes(event.type) || event.data?.usage === undefined) continue; + if (latest === undefined || (timestamp(event) ?? -Infinity) >= (timestamp(latest) ?? -Infinity)) + latest = event; + } + return latest?.data.usage; +} + +function efficiency(capture, roots, rootIds) { + const rootEvents = roots.flatMap(({ events }) => events); + const turns = selectEvents(rootEvents, "turn.started").length; + const steps = selectEvents(rootEvents, "step.started").length; + const toolCalls = new Set(); + for (const [index, { events }] of roots.entries()) { + for (const event of selectEvents(events, "actions.requested")) { + for (const action of event.data?.actions ?? []) { + if (TOOL_ACTION_KINDS.has(action?.kind)) + toolCalls.add(`${rootIds[index]}\0${action.callId}`); + } + } + } + const ratio = (numerator, denominator, reason) => + denominator === 0 ? notApplicable(reason) : measured(numerator / denominator); + const delegated = new Set( + selectEvents(rootEvents, "agent.started").map((event) => event.data?.sessionId), + ); + return { + "counts.turns": measured(turns), + "counts.steps": measured(steps), + "counts.steps_per_turn": ratio(steps, turns, "no-turns"), + "counts.tool_calls": measured(toolCalls.size), + "counts.tool_calls_per_step": ratio(toolCalls.size, steps, "no-model-steps"), + "counts.subagent_fan_out": measured(delegated.size), + "counts.subagent_depth": measured( + Math.max(...rootIds.map((id) => delegationDepth(capture, id))), + ), + "counts.compactions": measured(selectEvents(capture.events, "compaction.completed").length), + "counts.context_clears": measured(selectEvents(capture.events, "context.cleared").length), + }; +} + +/** Longest `agent.started` chain below a session; uncaptured children count as leaves. */ +function delegationDepth(capture, sessionId, visiting = new Set()) { + if (visiting.has(sessionId)) return 0; + const events = capture.bySession.get(sessionId) ?? []; + const children = new Set(selectEvents(events, "agent.started").map((e) => e.data?.sessionId)); + let depth = 0; + visiting.add(sessionId); + for (const child of children) + depth = Math.max(depth, 1 + delegationDepth(capture, child, visiting)); + visiting.delete(sessionId); + return depth; +} + +function reliability(events) { + const results = selectEvents(events, "action.result"); + const toolErrors = results.filter((event) => event.data?.status === "failed").length; + return { + "reliability.step_failures": measured(selectEvents(events, "step.failed").length), + "reliability.turn_failures": measured(selectEvents(events, "turn.failed").length), + "reliability.session_failures": measured(selectEvents(events, "session.failed").length), + "reliability.tool_errors": measured(toolErrors), + "reliability.tool_error_rate": + results.length === 0 + ? notApplicable("no-action-results") + : measured(toolErrors / results.length), + "reliability.truncations": measured( + selectEvents(events, "step.completed", { finishReason: "length" }).length, + ), + }; +} + +function failureCodes(events) { + const counts = new Map(); + for (const event of events) { + if (!FAILURE_EVENTS.includes(event.type)) continue; + const key = `${event.type}\0${typeof event.data?.code === "string" ? event.data.code : "UNKNOWN"}`; + counts.set(key, (counts.get(key) ?? 0) + 1); + } + return [...counts] + .map(([key, count]) => { + const [type, code] = key.split("\0"); + return { type, code, count }; + }) + .sort((a, b) => a.type.localeCompare(b.type) || a.code.localeCompare(b.code)); +} + +/** Find every eval detail artifact under one `.eve/evals/` tree. */ +export function listEvalArtifacts(runDirectory) { + const evalsDirectory = join(runDirectory, "evals"); + const files = []; + const visit = (directory) => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) visit(path); + else if (entry.name.endsWith(".json")) files.push(path); + } + }; + if (existsSync(evalsDirectory)) visit(evalsDirectory); + return files.sort(); +} + +// `node scripts/eval-metrics/standard.mjs <.eve/evals/>` prints each +// eval's outcome and metrics, then per-metric coverage across the run. +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const runDirectory = process.argv[2]; + if (runDirectory === undefined) { + console.error("Usage: node scripts/eval-metrics/standard.mjs <.eve/evals/>"); + process.exit(1); + } + const { classifyOutcome } = await import("./gaps.mjs"); + const coverage = new Map(Object.keys(STANDARD_METRICS).map((key) => [key, 0])); + const artifacts = listEvalArtifacts(runDirectory); + for (const path of artifacts) { + const artifact = JSON.parse(readFileSync(path, "utf8")); + const { metrics } = deriveAttemptMetrics(artifact); + console.log(`${artifact.id}: ${JSON.stringify(classifyOutcome(artifact))}`); + for (const [key, m] of Object.entries(metrics)) { + if (m.status === "measured") coverage.set(key, coverage.get(key) + 1); + console.log(` ${key} ${m.status === "measured" ? m.value : `${m.status} (${m.reason})`}`); + } + } + console.log(`\nCoverage over ${artifacts.length} evals:`); + for (const [key, count] of coverage) console.log(` ${key} ${count}/${artifacts.length}`); +} diff --git a/scripts/eval-metrics/standard.test.mjs b/scripts/eval-metrics/standard.test.mjs new file mode 100644 index 0000000000..51598d9f03 --- /dev/null +++ b/scripts/eval-metrics/standard.test.mjs @@ -0,0 +1,344 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { GAP_REASONS, classifyOutcome } from "./gaps.mjs"; +import { METRICS_VERSION, STANDARD_METRICS, deriveAttemptMetrics } from "./standard.mjs"; + +// Fixtures are trimmed `eve eval` detail artifacts captured from mock-model +// runs of e2e/fixtures/agent-tools and agent-subagents (mock models report +// no cost, so cost cases inject it below). +const fixture = (name) => + JSON.parse(readFileSync(new URL(`./fixtures/${name}.json`, import.meta.url), "utf8")); +const values = (metrics) => + Object.fromEntries( + Object.entries(metrics).map(([key, m]) => [key, m.status === "measured" ? m.value : m.status]), + ); +const at = (ms) => new Date(Date.UTC(2026, 0, 1) + ms).toISOString(); +const event = (type, ms, data = {}) => ({ + type, + meta: { id: `${type}@${ms}:${JSON.stringify(data)}`, at: at(ms) }, + data, +}); + +function assertPartition(metrics) { + const v = values(metrics); + const parts = + v["attribution.model_ms"] + + v["attribution.tool_ms"] + + v["attribution.task_ms"] + + v["attribution.compaction_ms"] + + v["attribution.framework_ms"]; + assert.equal(parts, v["latency.turn_ms"], "attribution must add up to non-human turn time"); +} + +test("derives every standard metric for a simple tool eval", () => { + const { metrics_version, metrics, failures } = deriveAttemptMetrics( + fixture("agent-tools-ends-turn-function"), + ); + assert.equal(metrics_version, METRICS_VERSION); + assert.deepEqual(Object.keys(metrics).sort(), Object.keys(STANDARD_METRICS).sort()); + assert.deepEqual(failures, []); + assert.deepEqual(values(metrics), { + "latency.eval_wall_ms": 1050, + "latency.turn_ms": 116, + "latency.turn_ms_mean": 58, + "latency.ttft_ms_p50": 4, + "latency.output_tokens_per_s": "unavailable", + "attribution.model_ms": 17, + "attribution.tool_ms": 3, + "attribution.task_ms": 0, + "attribution.human_wait_ms": 0, + "attribution.compaction_ms": 0, + "attribution.framework_ms": 96, + "cost.total_usd": "unavailable", + "cost.primary_usd": "unavailable", + "cost.subagent_usd": "unavailable", + "tokens.input": 1117, + "tokens.output": 29, + "tokens.cache_read": 0, + "tokens.cache_write": 0, + "tokens.cache_hit_ratio": 0, + "tokens.peak_context": 391, + "counts.turns": 2, + "counts.steps": 3, + "counts.steps_per_turn": 1.5, + "counts.tool_calls": 2, + "counts.tool_calls_per_step": 2 / 3, + "counts.subagent_fan_out": 0, + "counts.subagent_depth": 0, + "counts.compactions": 0, + "counts.context_clears": 0, + "reliability.step_failures": 0, + "reliability.turn_failures": 0, + "reliability.session_failures": 0, + "reliability.tool_errors": 0, + "reliability.tool_error_rate": 0, + "reliability.truncations": 0, + }); + // Mock models report no cost: missing cost is unavailable, never zero. + assert.equal(metrics["cost.primary_usd"].reason, "missing-step-cost"); + assert.equal(metrics["latency.output_tokens_per_s"].reason, "zero-stream-duration"); + assertPartition(metrics); +}); + +test("attributes delegated work to tasks across repeated captures of one session", () => { + const { metrics } = deriveAttemptMetrics(fixture("agent-subagents-continuation-local")); + const v = values(metrics); + assert.equal(v["attribution.task_ms"], 2465); + assert.equal(v["attribution.tool_ms"], 133); + assert.equal(v["counts.turns"], 3, "child sessions are not root turns"); + assert.equal(v["counts.subagent_fan_out"], 1); + assert.equal(v["counts.subagent_depth"], 1); + // Rolled-up usage includes the child's spend. + assert.equal(v["tokens.input"], 6006); + assertPartition(metrics); +}); + +test("excludes sign-in wait from latency and counts it as human wait", () => { + const { metrics } = deriveAttemptMetrics(fixture("agent-subagents-nested-authorization")); + const v = values(metrics); + assert.equal(v["attribution.human_wait_ms"], 174); + assert.equal(v["latency.eval_wall_ms"], 1072 - 174); + assertPartition(metrics); +}); + +test("closes a model step cut off by a session limit at its turn's end", () => { + const { metrics } = deriveAttemptMetrics(fixture("agent-subagents-usage-limit-tool")); + assert.equal(metrics["latency.turn_ms"].status, "measured"); + assertPartition(metrics); +}); + +test("compaction outside a turn does not count toward turn time", () => { + const { metrics } = deriveAttemptMetrics(fixture("agent-tools-compacted-image")); + assert.equal(values(metrics)["counts.compactions"], 1); + assert.equal(values(metrics)["attribution.compaction_ms"], 0); + assertPartition(metrics); +}); + +function withCosts(artifact, { stepCost, rolledUpCost }) { + const copy = structuredClone(artifact); + for (const session of copy.result.sessions) { + for (const e of session.events) { + if (session.sessionId !== copy.result.sessionId) continue; + if (e.type === "step.completed") e.data.usage.costUsd = stepCost; + if (["session.waiting", "turn.waiting"].includes(e.type)) e.data.usage.costUsd = rolledUpCost; + } + } + return copy; +} + +test("splits cost into primary steps and rolled-up subagent spend", () => { + const artifact = withCosts(fixture("agent-subagents-continuation-local"), { + stepCost: 0.25, + rolledUpCost: 4, + }); + const v = values(deriveAttemptMetrics(artifact).metrics); + assert.equal(v["cost.primary_usd"], 2.5, "ten primary steps"); + assert.equal(v["cost.total_usd"], 4); + assert.equal(v["cost.subagent_usd"], 1.5); +}); + +test("a single step without cost makes cost unavailable, and inconsistent rollups are flagged", () => { + const artifact = withCosts(fixture("agent-tools-ends-turn-function"), { + stepCost: 1, + rolledUpCost: 3, + }); + assert.equal(values(deriveAttemptMetrics(artifact).metrics)["cost.subagent_usd"], 0); + + const inconsistent = withCosts(artifact, { stepCost: 1, rolledUpCost: 2 }); + assert.equal( + deriveAttemptMetrics(inconsistent).metrics["cost.subagent_usd"].reason, + "inconsistent-cost-rollup", + ); + + const step = artifact.result.sessions[0].events.find((e) => e.type === "step.completed"); + delete step.data.usage.costUsd; + const { metrics } = deriveAttemptMetrics(artifact); + assert.equal(metrics["cost.primary_usd"].reason, "missing-step-cost"); + assert.equal(metrics["cost.subagent_usd"].reason, "missing-step-cost"); + assert.equal(metrics["cost.total_usd"].status, "measured"); +}); + +const synthetic = (events, extra = {}) => ({ + id: "synthetic", + verdict: "passed", + startedAt: at(0), + completedAt: at(1000), + result: { + sessionId: "s", + status: "completed", + sessions: [{ sessionId: "s", primary: true, events }], + }, + ...extra, +}); +const turn = (start, end, inner, terminal = "turn.completed") => [ + event("turn.started", start, { turnId: "t" }), + ...inner, + event(terminal, end, { turnId: "t" }), +]; + +test("overlapping parallel tools count once and streaming metrics use first deltas", () => { + const step = { turnId: "t", stepIndex: 0 }; + const events = turn(0, 1000, [ + event("step.started", 100, step), + event("reasoning.appended", 150, step), + event("message.appended", 300, step), + event("actions.requested", 400, { + ...step, + actions: [ + { kind: "tool-call", callId: "a" }, + { kind: "tool-call", callId: "b" }, + ], + }), + event("action.result", 700, { ...step, result: { callId: "b" }, status: "failed" }), + event("action.result", 600, { ...step, result: { callId: "a" }, status: "completed" }), + event("step.completed", 750, { ...step, usage: { outputTokens: 60, inputTokens: 900 } }), + ]); + const v = values(deriveAttemptMetrics(synthetic(events)).metrics); + assert.equal(v["attribution.tool_ms"], 300); + assert.equal(v["attribution.model_ms"], 350); + assert.equal(v["attribution.framework_ms"], 350); + assert.equal(v["latency.ttft_ms_p50"], 50); + assert.equal(v["latency.output_tokens_per_s"], 100); + assert.equal(v["reliability.tool_error_rate"], 0.5); + assert.equal(v["counts.tool_calls_per_step"], 2); +}); + +test("partial captures make attribution unavailable instead of guessing", () => { + const openTurn = [ + event("turn.started", 0, { turnId: "t" }), + event("step.started", 10, { turnId: "t", stepIndex: 0 }), + ]; + assert.equal( + deriveAttemptMetrics(synthetic(openTurn)).metrics["attribution.framework_ms"].reason, + "turn-incomplete", + ); + const orphanResult = turn(0, 100, [event("action.result", 50, { result: { callId: "x" } })]); + assert.equal( + deriveAttemptMetrics(synthetic(orphanResult)).metrics["attribution.tool_ms"].reason, + "action-start-missing", + ); + const openInput = turn(0, 100, [ + event("input.requested", 50, { turnId: "unknown", requests: [{ requestId: "r" }] }), + ]); + assert.equal( + deriveAttemptMetrics(synthetic(openInput)).metrics["attribution.human_wait_ms"].reason, + "input-incomplete", + ); + const noCapture = { ...synthetic([]), result: { status: "completed" } }; + assert.equal( + deriveAttemptMetrics(noCapture).metrics["latency.turn_ms"].reason, + "missing-session-capture", + ); + const conflicting = synthetic([event("turn.started", 0, { turnId: "t" })]); + conflicting.result.sessions.push({ + sessionId: "s", + events: [{ ...conflicting.result.sessions[0].events[0], data: { turnId: "other" } }], + }); + assert.equal( + deriveAttemptMetrics(conflicting).metrics["counts.turns"].reason, + "invalid-session-capture", + ); +}); + +test("a cancelled turn closes work it abandoned", () => { + const events = turn( + 0, + 500, + [ + event("step.started", 10, { turnId: "t", stepIndex: 0 }), + event("actions.requested", 100, { + turnId: "t", + stepIndex: 0, + actions: [{ kind: "tool-call", callId: "a" }], + }), + ], + "turn.cancelled", + ); + const v = values(deriveAttemptMetrics(synthetic(events)).metrics); + assert.equal(v["attribution.tool_ms"], 400); + assert.equal(v["attribution.model_ms"], 90); +}); + +test("counts failures by event and code", () => { + const events = turn( + 0, + 100, + [ + event("step.started", 10, { turnId: "t", stepIndex: 0 }), + event("step.failed", 20, { turnId: "t", stepIndex: 0, code: "MODEL_CALL_FAILED" }), + event("session.failed", 99, { code: "MODEL_CALL_FAILED" }), + ], + "turn.failed", + ); + events.at(-1).data.code = "MODEL_CALL_FAILED"; + const { failures, metrics } = deriveAttemptMetrics(synthetic(events)); + assert.deepEqual(failures, [ + { type: "session.failed", code: "MODEL_CALL_FAILED", count: 1 }, + { type: "step.failed", code: "MODEL_CALL_FAILED", count: 1 }, + { type: "turn.failed", code: "MODEL_CALL_FAILED", count: 1 }, + ]); + assert.equal(values(metrics)["reliability.step_failures"], 1); +}); + +test("classifies outcomes and every gap reason", () => { + const failed = (code, details, message = "failed") => + synthetic( + turn(0, 100, [ + event("step.failed", 50, { turnId: "t", stepIndex: 0, code, details, message }), + ]), + ); + const completedStep = event("step.completed", 50, { turnId: "t", stepIndex: 0 }); + + assert.deepEqual(classifyOutcome(fixture("agent-tools-ends-turn-function")), { + outcome: "completed", + }); + assert.deepEqual(classifyOutcome({ ...synthetic([]), verdict: "skipped" }), { + outcome: "skipped", + }); + for (const details of [ + { statusCode: 429 }, + { upstreamStatusCode: 503 }, + { semanticErrorId: "network-request-failed" }, + { detail: "TypeError: fetch failed [cause]: UND_ERR_HEADERS_TIMEOUT" }, + ]) + assert.deepEqual(classifyOutcome(failed("MODEL_CALL_FAILED", details)), { + outcome: "gap", + gap_reason: "provider-unavailable", + }); + for (const details of [{ statusCode: 400 }, { statusCode: 413 }]) + assert.equal(classifyOutcome(failed("MODEL_CALL_FAILED", details)).outcome, "completed"); + assert.equal(classifyOutcome(failed("OUTPUT_SCHEMA_NOT_FULFILLED", {})).outcome, "completed"); + assert.equal( + classifyOutcome(failed("WORKFLOW_STREAM_WRITE_FAILED", {})).gap_reason, + "stream-write-failed", + ); + + const timeout = "The operation was aborted due to timeout"; + assert.equal( + classifyOutcome(synthetic([], { error: timeout })).gap_reason, + "timeout-before-first-step", + ); + assert.equal( + classifyOutcome(synthetic([], { error: "connect ECONNREFUSED" })).gap_reason, + "error-before-first-step", + ); + assert.deepEqual(classifyOutcome(synthetic([completedStep], { error: timeout })), { + outcome: "timed_out", + }); + const parked = synthetic([ + event("turn.started", 0, { turnId: "t" }), + completedStep, + event("turn.waiting", 60, { turnId: "t", on: "input" }), + ]); + assert.deepEqual(classifyOutcome(parked), { outcome: "parked" }); + + assert.deepEqual(GAP_REASONS, [ + "job-missing", + "eval-missing", + "provider-unavailable", + "stream-write-failed", + "timeout-before-first-step", + "error-before-first-step", + ]); +});