diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 5423e735..fb49ef7d 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -10,12 +10,17 @@ name: Docs # Source: "Deploy from a branch" → branch `gh-pages` / `/ (root)`. That's the same # (legacy, branch-based) configuration uipath-python already runs on. on: - push: - branches: [main] - paths: - - "docs/**" - - "mkdocs.yml" - - ".github/workflows/docs.yml" + # Auto-publish on push is DISABLED: GitHub Pages is not yet enabled for this + # repo on uipath.github.io (org owner must switch it on once — Settings → Pages + # → Deploy from a branch → gh-pages / root). The published docs currently serve + # from coder-eval.com/docs (synced separately by the website), so the gh-pages + # deploy is not needed yet. Re-enable by uncommenting the `push:` trigger below. + # push: + # branches: [main] + # paths: + # - "docs/**" + # - "mkdocs.yml" + # - ".github/workflows/docs.yml" workflow_dispatch: permissions: diff --git a/CLAUDE.md b/CLAUDE.md index 87b531a6..67b5b1eb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -195,11 +195,17 @@ make format # ruff format make check # ruff check (lint) make typecheck # pyright make test # pytest -make lint # custom architectural lint rules (CE001–CE025) +make lint # custom architectural lint rules (CE001+) make verify # All of the above + coverage check (CI equivalent) ``` -When fixing a bug, ask: *could a custom lint rule have prevented this?* If the root cause is a mechanically detectable pattern (e.g., "always import from `coder_eval.models`", "never call blocking IO in async"), add a rule to `tests/lint/rules/` following the CE001–CE025 pattern and wire it up in `tests/lint/runner.py`. This turns a one-time fix into permanent enforcement. See `tests/test_custom_lint.py` for how rules are tested. +When fixing a bug, ask: *could a custom lint rule have prevented this?* If the root cause is a mechanically detectable pattern (e.g., "always import from `coder_eval.models`", "never call blocking IO in async"), add a rule to `tests/lint/rules/` following the CE001+ pattern and wire it up in `tests/lint/runner.py`. This turns a one-time fix into permanent enforcement. See `tests/test_custom_lint.py` for how rules are tested. (Doc-surface / whole-tree rules that reason over Markdown/YAML or the entire `src/` tree rather than one `.py` AST at a time — CE027–CE031 — are not `BaseRule`s in the runner; they are wired as dedicated `@pytest.mark.lint` test classes. CE031 guards against dead config: a behavior-driving field on `SimulationConfig`/`RunLimits`/`Dataset` that no code reads by name.) + +Adding a user-facing field to one of the models CE030 tracks (`TaskDefinition`, `RunLimits`, `Dataset`, `SimulationConfig` — see `tests/lint/doc_schema_parity.py`) means documenting it in its guide (mention the field name as inline code) or adding an `EXEMPT` entry with a reason it is not user-authored. `make lint` fails otherwise. + +**Docs index SSOT.** `nav:` plus `extra.docs_index` (blurbs) in `mkdocs.yml` are the single source of truth for the flat index surfaces — `README.md`'s Documentation table, `docs/index.md`'s "Where to go next" table, and the `## Docs` / `## Tutorials` sections of `docs/llms.txt`. Regenerate all three with `make docs-indexes`; **CE028** fails the build if any drifts, if a nav page lacks a blurb (or vice-versa), or if a `docs/*.md` page is missing from the nav. The website sidebar derives from the same `nav:`. When adding or renaming a docs page, edit `nav:` + `extra.docs_index` and run `make docs-indexes` — never hand-edit the generated tables (they sit between `` / `` markers). + +**Anchor slugger convention.** The docs are rendered by three sluggers (GitHub, Starlight/github-slugger on coder-eval.com, and python-markdown/mkdocs), which disagree on headings containing `&` or punctuation (`api-routing--benchmarking` vs `api-routing-benchmarking`). Prefer punctuation-free headings so all three agree; if a heading needs `&`, add a GitHub-form `` shim above it and link that form. Verify a new intra-doc anchor link resolves in the built HTML (`mkdocs build`), not by eye. ## Configuration diff --git a/Makefile b/Makefile index 79bb01d4..dcc075ca 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help install format check typecheck test test-live test-smoke verify verify-noextra clean run lint docker-image docker-image-full coder-eval-runtime docker-images +.PHONY: help install format check typecheck test test-live test-smoke verify verify-noextra clean run lint docs-indexes docker-image docker-image-full coder-eval-runtime docker-images # Single source of the installed coder-eval version (used to tag the docker # images). Referenced lazily inside the docker recipes, so it doesn't run on @@ -24,6 +24,9 @@ check: ## Run linting checks lint: ## Run custom architectural lint rules (CE001+) uv run pytest tests/test_custom_lint.py -v --tb=short --no-header -p no:warnings +docs-indexes: ## Regenerate README/docs indexes from the mkdocs nav (SSOT) + uv run python -m tests.lint.doc_indexes + typecheck: ## Run type checking with pyright uv run pyright diff --git a/README.md b/README.md index 4c63d01e..ab7599ab 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,10 @@ [![PyPI](https://img.shields.io/pypi/v/coder-eval.svg)](https://pypi.org/project/coder-eval/) [![Website](https://img.shields.io/badge/website-coder--eval.com-1f6feb.svg)](https://coder-eval.com) -[![Docs](https://img.shields.io/badge/docs-uipath.github.io%2Fcoder__eval-1f6feb.svg)](https://uipath.github.io/coder_eval/) +[![Docs](https://img.shields.io/badge/docs-coder--eval.com%2Fdocs-1f6feb.svg)](https://coder-eval.com/docs) [![License: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE) [![Python 3.13+](https://img.shields.io/badge/python-3.13%2B-blue.svg)](https://www.python.org/downloads/) [![CI](https://github.com/UiPath/coder_eval/actions/workflows/pr-checks.yml/badge.svg)](https://github.com/UiPath/coder_eval/actions/workflows/pr-checks.yml) -[![Code style: Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) **Coder Eval** (`pip install coder-eval` / `uv tool install coder-eval`) is an open-source framework for **evaluating and benchmarking AI coding agents and their skills** — built for CLI @@ -22,8 +21,8 @@ prompt), or **gate CI on coding-agent quality**. Unlike fixed datasets (SWE-benc SkillsBench) that rank models on a shared leaderboard, Coder Eval evaluates the tasks, skills, and workflows *you* ship — with weighted 0.0–1.0 criteria, a `skill_triggered` activation check, an A/B experiment layer, and per-tool cost -telemetry. See [How it compares](https://uipath.github.io/coder_eval/comparison/). -📚 **Full docs:** **[uipath.github.io/coder_eval](https://uipath.github.io/coder_eval/)**. +telemetry. See [How it compares](https://coder-eval.com/docs/comparison). +📚 **Full docs:** **[coder-eval.com/docs](https://coder-eval.com/docs)**.

Coder Eval running the hello_date task: a sandboxed agent writes and runs a script from a YAML task, then the scored result is browsed in evalboard @@ -49,7 +48,7 @@ telemetry. See [How it compares](https://uipath.github.io/coder_eval/comparison/ > **Keeping skills fresh?** Run Coder Eval as a scheduled GitHub Actions job so your > skills are continuously re-evaluated against the latest model — a skill that quietly > stops triggering surfaces as a failing criterion before your users hit it. See -> **[Tutorial 02 — Running coder_eval in CI](docs/tutorials/02-ci-pipeline.md)**. +> **[Tutorial 02 — Running Coder Eval in CI](docs/tutorials/02-ci-pipeline.md)**. ## Quick Start @@ -93,7 +92,7 @@ To add it as a project dependency instead: `uv add coder-eval` or `pip install coder-eval`. In a real CI gate, pin to a specific released version so a harness upgrade can't silently move your results. (The example `tasks/` live in this repo — clone it or point the CLI at your own task files.) See -[Tutorial 02 — Running coder_eval in CI](docs/tutorials/02-ci-pipeline.md) for +[Tutorial 02 — Running Coder Eval in CI](docs/tutorials/02-ci-pipeline.md) for the full setup. ## Use as a GitHub Action @@ -176,15 +175,27 @@ alone. ## Documentation + | Guide | What's in it | | --- | --- | | [Tutorials](docs/tutorials/README.md) | Step-by-step walkthroughs — start here | | [User Guide](docs/USER_GUIDE.md) | Full CLI, configuration, output, and environment-variable reference | | [Task Definition Guide](docs/TASK_DEFINITION_GUIDE.md) | The task-file schema — all criterion types, scoring, templates | +| [Claude Code](docs/agents/CLAUDE_CODE.md) | Configuring and running the default Claude Code agent | +| [Codex](docs/agents/CODEX.md) | Running the OpenAI Codex agent | +| [Antigravity (Gemini)](docs/agents/ANTIGRAVITY.md) | Running the Google Antigravity / Gemini agent | | [A/B Experiments](docs/AB_EXPERIMENTS.md) | Compare models / tools / prompts across the same tasks | -| [Bring Your Own Dataset](docs/BYOD.md) | Fan a single task out over a dataset | -| [Codex Agent Guide](docs/CODEX_AGENT_GUIDE.md) | Running the Codex agent | -| [Docker Isolation](docs/DOCKER_ISOLATION.md) | The container sandbox driver | +| [Bring Your Own Dataset](docs/DATASETS.md) | Fan a single task out over a dataset | +| [Dialog Mode](docs/DIALOG_MODE.md) | Evaluate agents in multi-turn conversation via a simulated user | +| [Docker Isolation](docs/DOCKER_ISOLATION.md) | The container sandbox driver, with custom images | +| [CI Gate & GitHub Action](docs/CI_GATE.md) | Run Coder Eval as a CI gate — the packaged Action, JUnit output, score floor | +| [Extending Coder Eval](docs/EXTENDING.md) | Author a custom agent, criterion, or model pricing via the plugin SPI | +| [Report Schema](docs/REPORT_SCHEMA.md) | Field-level reference for run.json / variant.json / task.json | +| [How It Compares](docs/comparison.md) | vs. SWE-bench, SkillsBench, Harbor, OpenAI Evals, hand-rolled scripts | + + +| Repo doc | What's in it | +| --- | --- | | [CLAUDE.md](CLAUDE.md) | Architecture, key patterns, and extension points | | [CONTRIBUTING.md](CONTRIBUTING.md) | Dev setup, quality bar, and how to contribute | @@ -192,7 +203,7 @@ alone. - **vs. fixed benchmarks (SWE-bench, SkillsBench)** — they score a canonical dataset; Coder Eval scores *your* tasks with continuous 0.0–1.0 weighted criteria (and can - still wrap a fixed dataset via [Bring Your Own Dataset](docs/BYOD.md)). + still wrap a fixed dataset via [Bring Your Own Dataset](docs/DATASETS.md)). - **vs. large-scale / RL harnesses (Harbor)** — Harbor targets scale and RL rollouts; Coder Eval targets weighted, skill-aware suites gated in CI. - **vs. model-output eval tools (OpenAI Evals)** — they grade model text; Coder Eval @@ -200,7 +211,7 @@ alone. - **vs. hand-rolled scripts** — reproducible sandboxes, weighted criteria, cost/token telemetry, A/B experiments, and CI-ready pass/fail gates out of the box. -See the full [comparison — with sources](https://uipath.github.io/coder_eval/comparison/). +See the full [comparison — with sources](https://coder-eval.com/docs/comparison). ## Task Definition diff --git a/docs/AB_EXPERIMENTS.md b/docs/AB_EXPERIMENTS.md index 0f437bc3..629b93ae 100644 --- a/docs/AB_EXPERIMENTS.md +++ b/docs/AB_EXPERIMENTS.md @@ -1,6 +1,6 @@ --- description: >- - A/B-test AI coding agents with coder_eval's experiment layer — Claude Code vs. + A/B-test AI coding agents with Coder Eval's experiment layer — Claude Code vs. Codex vs. Gemini, model vs. model, skill on vs. off, prompt vs. prompt — on identical tasks. --- @@ -122,7 +122,7 @@ From `ExperimentVariant` (`coder_eval/models/experiment.py`): | `variant_id` | str | Unique arm identifier (required) | | `description` | str | Human-readable label shown in reports | | `agent` | dict | Partial `AgentConfig` overrides (model, plugins, tools, system_prompt, sdk_options, …) | -| `simulation` | dict | Partial `SimulationConfig` overrides (persona/model/temperature per arm) | +| `simulation` | dict | Partial `SimulationConfig` overrides (persona, goal, `n_trials`, … per arm) — see [Dialog Mode](DIALOG_MODE.md) | | `repeats` | int | Replicate count for this arm (overrides experiment default) | | `template_sources` | list | Extra templates appended after the task base (e.g. a docs overlay) | | `prompt_mutations` | list | Ordered mutations applied to `initial_prompt` | @@ -212,8 +212,9 @@ variants: ## Recipe: A/B a Prompt -Use `prompt_mutations` (transform the task prompt) or `initial_prompt` (replace -it wholesale). They are mutually exclusive per variant. +Use `prompt_mutations` (transform the task prompt) or `initial_prompt` / +`initial_prompt_file` (replace it wholesale). All three are mutually exclusive +per variant — setting two raises at load. ```yaml experiment_id: prompt-phrasing @@ -224,11 +225,45 @@ variants: - variant_id: detailed prompt_mutations: - type: suffix - text: "\n\nThink step by step and validate your work before finishing." + content: "Think step by step and validate your work before finishing." ``` -The full mutation catalog (prefix / suffix / replace / template) is defined in -`coder_eval/models/mutations.py`. +### The mutation catalog + +Four mutation types, applied to the base `initial_prompt` at **variant +resolution time** — before the task ever reaches the orchestrator: + +| `type` | Fields | Defaults | +| ---------- | ------------------------------------------------- | --------------------- | +| `prefix` | `content` (required), `separator` | `separator: "\n\n"` | +| `suffix` | `content` (required), `separator` | `separator: "\n\n"` | +| `replace` | `pattern`, `replacement` (both required), `regex` | `regex: false` | +| `template` | `variables` (mapping, required) | — | + +- **Ordered.** `prompt_mutations` is a list and each mutation operates on the + result of the one before it. A `replace` listed after a `suffix` will rewrite + the appended text too, which is occasionally what you want and more often a + surprise — keep the list short and ordered deliberately. +- **`prefix` / `suffix`** join with `separator`, default `"\n\n"` (write it + quoted in YAML so the escape is interpreted). +- **`replace`** is a literal `str.replace` by default. With `regex: true` it + becomes `re.sub`, so `pattern` is a Python regular expression and + `replacement` follows `re.sub` semantics — `\1` backreferences work, and an + unanchored pattern can match far more than you intended. +- **`template`** substitutes literal `{name}` occurrences with the mapped value + via plain string replacement — **not** `str.format`, and unrelated to the + `${row.}` syntax used by [datasets](DATASETS.md). A `{name}` with no + matching entry in `variables` is left in the prompt verbatim rather than + raising, so a typo in a variable name fails silently at the *prompt* level. + Read the rendered prompt in the report to confirm. + +Every mutation model sets `extra="forbid"`, so a misspelled field (`text:` +instead of `content:`) is a hard `ValidationError` at load — never a silently +ignored no-op. + +When both `defaults.prompt_mutations` and a variant's `prompt_mutations` are set, +they **compose** — the experiment-defaults mutations apply first, then the +variant's, on the already-mutated prompt. ## Recipe: Smoke vs. e2e Flavors (Early Stop) diff --git a/docs/BYOD.md b/docs/BYOD.md deleted file mode 100644 index 5284d9cd..00000000 --- a/docs/BYOD.md +++ /dev/null @@ -1,210 +0,0 @@ ---- -description: >- - Use a custom Docker image with coder_eval — extend the base coder-eval-agent - image with your own dependencies and tools, then point task configuration at - it. ---- - -# Bring Your Own Docker (BYOD) - -The BYOD feature allows customers to use custom Docker images that extend the base `coder-eval-agent` image, enabling them to add custom dependencies and tools while maintaining the latest coder-eval codebase. - -## Overview - -By default, coder-eval uses the official `coder-eval-agent` image built via `make docker-image`. With BYOD, you can: - -1. **Extend the base image** with custom dependencies -2. **Override the image tag** in your task configuration -3. **Run evaluations** with your custom image while leveraging all coder-eval features - -## How It Works - -### Architecture - -- **`DockerDriverConfig.image`** field now defaults to `DEFAULT_IMAGE_TAG` (e.g., `coder-eval-agent:0.1.0`) -- **No environment variable override** needed—use task configuration instead -- **Simple precedence**: Task config → Default image - -### Building a Custom Image - -Create a `Dockerfile` in your repo: - -```dockerfile -FROM coder-eval-agent:0.1.0 - -# Install custom dependencies -RUN apt-get update && apt-get install -y \ - custom-tool \ - another-dependency \ - && rm -rf /var/lib/apt/lists/* - -# Add custom scripts or configuration -COPY custom-script.sh /usr/local/bin/ -RUN chmod +x /usr/local/bin/custom-script.sh -``` - -Build the image: - -```bash -docker build -t my-custom-image:0.1.0 . -``` - -### Using a Custom Image in Tasks - -Update your task YAML to reference the custom image: - -```yaml -task_id: my_task -description: Task using custom Docker image - -sandbox: - driver: docker - docker: - image: my-custom-image:0.1.0 # Your custom image - network: bridge - limits: - timeout: 300 - -initial_prompt: | - Use the custom tools installed in my-custom-image to complete this task. - -success_criteria: - - type: file_exists - path: output.txt - description: Verify output file was created -``` - -## Example: BYOD Smoke Test - -The codebase includes a smoke test that demonstrates BYOD: - -**Task**: `tasks/byod_smoke_test.yaml` -- Uses custom image `byod-custom-image:0.1.0` -- Verifies the custom image was loaded via a marker file -- Tagged `smoke-pass` for CI/CD pipeline - -**Template**: `templates/byod_smoke_test/Dockerfile` -- Extends `coder-eval-agent:0.1.0` -- Adds `/opt/byod_marker` to prove custom image was used - -Run locally: - -```bash -# Build the custom image -docker build -t byod-custom-image:0.1.0 templates/byod_smoke_test/ - -# Run the smoke test -set -a && source .env && set +a -coder-eval run tasks/byod_smoke_test.yaml -``` - -## Configuration Details - -### DockerDriverConfig.image - -- **Type**: `str` (not optional) -- **Default**: `DEFAULT_IMAGE_TAG` (e.g., `coder-eval-agent:0.1.0`) -- **Override**: Set in task YAML via `sandbox.docker.image` - -### Example Configuration Layers - -```yaml -# Default (no override) -sandbox: - driver: docker - # image defaults to coder-eval-agent:0.1.0 - -# Override at task level -sandbox: - driver: docker - docker: - image: my-team/image:latest - -# Override via CLI (future enhancement) -# coder-eval run task.yaml --docker-image custom:v1 -``` - -## Best Practices - -1. **Base image consistency**: Always extend from the same `coder-eval-agent` version as your host - ```dockerfile - FROM coder-eval-agent:0.1.0 # Match your host version - ``` - -2. **Keep images lean**: Add only necessary dependencies - ```dockerfile - # Good: Clean up after install - RUN apt-get update && apt-get install -y tool && rm -rf /var/lib/apt/lists/* - - # Avoid: Large intermediate layers - RUN apt-get update - RUN apt-get install -y tool - ``` - -3. **Document custom tools**: Make it clear what you added - ```dockerfile - # Custom tools added for project X - # - tool-a: used for step 1 - # - tool-b: used for step 2 - RUN apt-get install -y tool-a tool-b - ``` - -4. **Version your images**: Use semver tags - ```bash - docker build -t my-company/evaluation-agent:1.0.0 . - docker build -t my-company/evaluation-agent:latest . - ``` - -5. **Test locally first**: Verify your image works before adding to CI - ```bash - docker build -t test-image:local . - coder-eval run task.yaml # Ensure task.yaml points to test-image:local - ``` - -## Troubleshooting - -### Image Not Found - -**Error**: `docker: Error response from daemon: pull access denied` - -**Solution**: Ensure the image exists and the tag is correct -```bash -# List available images -docker images | grep byod - -# Rebuild if missing -docker build -t byod-custom-image:0.1.0 templates/byod_smoke_test/ -``` - -### Version Mismatch - -**Warning**: `Image coder-eval-agent:0.1.0 != host 0.1.0` - -**Solution**: Rebuild the custom image after updating the base image -```bash -# Update base image -make docker-image - -# Rebuild custom image -docker build --no-cache -t my-image:0.1.0 . -``` - -## CI/CD Integration - -The BYOD feature is tested in the smoke-pass bucket of the e2e-smoke job: - -1. Base image is built: `make docker-image` -2. BYOD template is built: `docker build templates/byod_smoke_test/` -3. Smoke test runs: `coder-eval run tasks/*.yaml --tags smoke-pass` -4. Results verified: `byod_smoke_test` should succeed - -See `.github/workflows/pr-checks.yml` for the full pipeline. - -## Related Files - -- **Implementation**: `src/coder_eval/models/sandbox.py` (DockerDriverConfig) -- **Runner**: `src/coder_eval/isolation/docker_runner.py` (image selection) -- **Tests**: `tests/test_byod_feature.py` -- **Example Task**: `tasks/byod_smoke_test.yaml` -- **Example Dockerfile**: `templates/byod_smoke_test/Dockerfile` -- **CI/CD**: `.github/workflows/pr-checks.yml` (e2e-smoke, windows-smoke jobs) diff --git a/docs/CI_GATE.md b/docs/CI_GATE.md new file mode 100644 index 00000000..72791b69 --- /dev/null +++ b/docs/CI_GATE.md @@ -0,0 +1,147 @@ +--- +description: >- + Run Coder Eval as a CI gate — the packaged composite GitHub Action, JUnit XML + output for test-report ingestion, and an optional per-task score floor. +--- + +# CI Gate: GitHub Action & JUnit reports + +Coder Eval ships a **packaged CI gate**: a composite GitHub Action that installs +the CLI, runs your tasks, emits a JUnit XML report, appends the run summary to the +job summary, and fails the build on any task/gate failure. This page is the +reference for the Action and the JUnit output. For a step-by-step walkthrough +(including a hand-rolled workflow), see +[Tutorial 02 — Running Coder Eval in CI](tutorials/02-ci-pipeline.md). + +## The GitHub Action + +A composite action lives at the repo root (`action.yml`), so you can reference it +directly: + +```yaml +- uses: UiPath/coder_eval@v0 # becomes @v1 once 1.0.0 ships; @vX.Y.Z pins exactly + with: + tasks: tests/tasks/**/*.yaml + model: claude-sonnet-5 + env: | + ANTHROPIC_API_KEY=${{ secrets.ANTHROPIC_API_KEY }} +``` + +The action is **agent-agnostic** — it installs `coder-eval` but *not* any +coding-agent runtime. Tasks using the default `claude-code` agent need the +`claude` CLI on `PATH` (Node + `@anthropic-ai/claude-code`), provided by your job +*before* this step runs. + +### Inputs + +| Input | Default | Purpose | +| --- | --- | --- | +| `tasks` | *(all `tasks/`)* | Task YAML path(s)/glob passed to `coder-eval run`. | +| `tags` | — | Only run tasks matching these comma-separated tags (`--tags`). | +| `model` | — | Override agent model for all tasks (`--model`). | +| `extra-args` | — | Extra args appended verbatim to `coder-eval run` (`--experiment`, `-D …`, `--exclude-tags`, …). Trusted caller input. | +| `version` | pinned release | `coder-eval` version to install from PyPI, or `local` to install from the action checkout. | +| `run-dir` | `runs/ci` | Run directory (`--run-dir`). | +| `junit-path` | `coder-eval-junit.xml` | Where to write the JUnit XML report. | +| `step-summary` | `true` | Append `run.md` to the GitHub job summary. | +| `env` | — | Credential/backend passthrough (see below). | +| `minimum-task-score` | *(off)* | Optional strict per-task score floor (see below). | + +### Outputs + +| Output | Description | +| --- | --- | +| `run-dir` | The run directory containing `run.json` / `run.md`. | +| `junit-path` | Path to the written JUnit XML report. | + +### Credentials via `env` + +**`env` is the sole channel for credentials and backend config.** It takes +newline-separated `NAME=VALUE` pairs, exported for the `coder-eval` process +**only** — scoped to the run step, never written to `$GITHUB_ENV`, so a forwarded +secret can't bleed into later job steps. Names must match +`^[A-Za-z_][A-Za-z0-9_]*$`; blank lines and `#` comments are ignored. Always wire +values from repository secrets — never inline a secret literal. + +```yaml +- uses: UiPath/coder_eval@v0 + with: + tasks: tests/tasks/**/*.yaml + env: | + ANTHROPIC_API_KEY=${{ secrets.ANTHROPIC_API_KEY }} + API_BACKEND=direct +``` + +Set whatever the run needs — `ANTHROPIC_API_KEY`, `API_BACKEND`, Bedrock/model +vars, `GEMINI_API_KEY` for Antigravity, `EVALBOARD_*`, plugin paths, etc. See the +[User Guide → Environment Variables](USER_GUIDE.md#environment-variables) and the +per-agent guides ([Claude Code](agents/CLAUDE_CODE.md) · [Codex](agents/CODEX.md) · +[Antigravity](agents/ANTIGRAVITY.md)) for what each backend needs. + +### The score floor (`minimum-task-score`) + +An **additional** gate on top of `coder-eval`'s own exit code. Set a float in +`[0.0, 1.0]` and the step fails if **any** scored task, in any variant, has a +`weighted_score` below it — *or* if `coder-eval` itself exits non-zero (both +verdicts surface). It reads the always-written `run.json` spine +(`task_results[*].weighted_score`), so it works for plain and experiment runs +alike. Errored tasks (null score) are left to `coder-eval`'s exit code; a +malformed/`NaN` score fails closed. Empty (the default) disables the floor. + +### Security + +Evaluated tasks execute agent-generated code. **Do not** run this action under +`pull_request_target` with secrets exposed to untrusted fork PRs. For untrusted +tasks, use the [Docker driver](DOCKER_ISOLATION.md) — the `tempdir` driver is not +a security boundary. + +> The README's [Use as a GitHub Action](https://github.com/UiPath/coder_eval#use-as-a-github-action) +> section carries the same reference alongside a copy-paste workflow. + +## JUnit XML output + +Any run can emit a JUnit XML report — the lingua franca CI platforms understand for +per-test annotations, history, and flake tracking. Two entry points, one code path +(so they can't drift): + +```bash +# During a run +coder-eval run tasks/*.yaml --junit-xml coder-eval-junit.xml + +# After the fact, from a finished run dir +coder-eval report runs/latest -f junit # writes runs/latest/junit.xml +coder-eval report runs/latest -f junit -o out.xml # custom path +``` + +The report is built **from the finalized run directory on disk** — the `run.json` +spine (required), any `suite.json` gates (optional), and per-failed-row `task.json` +for failure detail (best-effort). Each task result maps to a JUnit `testcase`; +failures/errors carry a (capped) detail body, and statuses map through +`FinalStatus.category` (`succeeded` / `failed` / `error`). See the +[Report Schema](REPORT_SCHEMA.md) for the underlying fields. + +### Ingesting the report + +**GitHub Actions** — [`mikepenz/action-junit-report`](https://github.com/mikepenz/action-junit-report): + +```yaml +- uses: mikepenz/action-junit-report@v5 + if: always() + with: + report_paths: coder-eval-junit.xml +``` + +**Azure DevOps** — `PublishTestResults@2`: + +```yaml +- task: PublishTestResults@2 + inputs: + testResultsFormat: 'JUnit' + testResultsFiles: 'coder-eval-junit.xml' +``` + +## See also + +- [Tutorial 02 — Running Coder Eval in CI](tutorials/02-ci-pipeline.md) — the walkthrough +- [Report Schema](REPORT_SCHEMA.md) — the JSON the JUnit report is built from +- [User Guide](USER_GUIDE.md) — the `run` / `report` commands and environment variables diff --git a/docs/DATASETS.md b/docs/DATASETS.md new file mode 100644 index 00000000..498addab --- /dev/null +++ b/docs/DATASETS.md @@ -0,0 +1,276 @@ +--- +description: >- + Fan one Coder Eval task out over a dataset — inline rows or JSONL files, row + substitution into prompts and criteria, sampling, and suite-level scoring + across every row. +--- + +# Bring Your Own Dataset + +A `dataset:` block turns **one** task file into **N** independent evaluation tasks — one per row. +Write the prompt and the success criteria once, parameterize them with `${row.}`, and point +the task at a list of rows. This is how Coder Eval runs benchmark-sized suites (classification sets, +skill-activation corpora, regression fixtures) without duplicating YAML. + +The `dataset:` field is documented field-by-field in the +[Task Definition Guide](TASK_DEFINITION_GUIDE.md#dataset). This page is the how-and-why. + +## How fan-out works + +Expansion happens in `orchestration/task_loader.expand_dataset` at load time, **before** experiment +variant resolution. For each row: + +- `task_id` becomes `/` — e.g. `sentiment-classification/r1`. +- `suite_id` is set to the original `task_id`, and `row_id` to the row's identifier. Both are set by + the expander; you never author them. +- `dataset` is cleared on the expanded task, so nothing re-expands downstream. +- Every row-task runs in its **own sandbox** and writes its **own run directory** and `task.json`, + exactly like a hand-written task. + +Because expansion runs before variant resolution, **an experiment variant cannot override the +dataset** — every arm of an A/B sees the identical row set, which is what makes the comparison fair. + +Rows that share a `suite_id` roll up into a per-suite `suite.json` / `suite.md` alongside the normal +run report. The [Report Schema](REPORT_SCHEMA.md) has the field-level contract in its `suite.json` +section. + +## Two row sources + +Exactly one of `rows` or `paths` must be set. Both, or neither, raises at load time. + +### Inline rows + +Best for a handful of rows that belong with the task: + +```yaml +dataset: + rows: + - id: alpha + expected: "alpha" + - id: beta + expected: "beta" +``` + +### JSONL files + +Best for real datasets. Each path is **relative to the task YAML's directory** (not your working +directory), and multiple paths are concatenated into one row stream in declared order: + +```yaml +dataset: + paths: + - "datasets/sentiment.jsonl" + - "datasets/sentiment_extra.jsonl" +``` + +Each line is one JSON object — the same shape as an inline row: + +```json +{"id": "r1", "text": "This product is fantastic, I love it.", "expected": "positive"} +``` + +## Substituting row values + +`${row.}` placeholders are replaced with that row's value in exactly two places: + +1. the task's `initial_prompt`, and +2. every **string leaf** of every `success_criteria` entry (at any nesting depth). + +Nothing else is substituted — not the task-level `description`, not `pre_run`, not `sandbox`. Keep +row-varying data in the prompt and the criteria. (A *criterion's* own `description` is a string leaf +of `success_criteria`, so it **is** substituted — see the example below.) + +```yaml +initial_prompt: "Classify this text: ${row.text}" + +success_criteria: + - type: "classification_match" + path: "result.txt" + expected_label: "${row.expected}" + allowed_labels: [positive, negative] + description: "Predicted label matches expected for row ${row.id}" +``` + +Two failure modes, both raised at load time rather than producing a silently wrong prompt: + +- a placeholder naming a field the row doesn't have → `KeyError: ${row.foo}: key not found + (available: [...])`; +- a placeholder resolving to a list or dict → `TypeError: ${row.foo}: value must be a scalar`. + +A field whose value is `null` substitutes as the empty string. That is the shape a classification +suite wants for a negative row — "this row has no expected label" — though such rows are more often +written with an explicit `""`. + +## Row identifiers + +`id_field` (default `id`) names the row field used as the row's identity. That value becomes a +directory name under the run dir, so it is validated: it must match +`^[A-Za-z0-9_][A-Za-z0-9_.\-]*$` — letters, digits, underscore, hyphen, dot, not starting with a +dot or hyphen — and must be unique across the dataset. + +```yaml +dataset: + id_field: "case_id" # rows carry `case_id` instead of `id` + paths: ["datasets/cases.jsonl"] +``` + +Load-time errors, with their message shapes: + +| Situation | Message | +| --- | --- | +| No rows at all | `Dataset for task '' is empty` | +| Row lacks the id field | `Dataset row for task '' missing id_field '': ` | +| Id has illegal characters | `Dataset row id '' must match ^[A-Za-z0-9_][A-Za-z0-9_.\-]*$ (letters, digits, underscore, hyphen, dot)` | +| Two rows share an id | `Duplicate dataset row id for task '': ''` | +| Both/neither `rows` and `paths` | `Dataset must specify either 'paths' or 'rows'` / `... only one of ...` | +| `paths: []` | `Dataset.paths must be a non-empty list` | + +## Sampling a subset + +A full dataset is expensive. Two independent mechanisms cut it down, and **`--sample` wins whenever +both apply**: + +1. **`--sample N` (CLI only)** — a flat uniform-random N rows over the whole dataset. Fixed seed, so + the same N rows come back every run: a reproducible, cheap smoke flavor of a big suite. Unlike a + first-N slice it is unbiased across concatenated `paths`. If `N` is greater than or equal to the + dataset size, the whole dataset runs — not an error. +2. **Stratified: `--sample-per-stratum N` (CLI) or `dataset.sample_per_stratum: N` (YAML)** — keep up + to N rows per stratum, where a row's stratum is the value of `stratify_field` (default + `expected_skill`). Strata with N rows or fewer are taken whole. The CLI flag overrides the YAML + value, so a runner can cap a dataset without editing the task. Rows missing `stratify_field` fall + into the `""` stratum. This is the right sampler for classification suites, where a uniform draw + would starve rare strata. + +!!! warning "Stratified sampling is nondeterministic by default" + + The stratified draw is seeded **only** by `dataset.sample_seed`. When `sample_seed` is unset the + sample is deliberately **re-drawn on every run** — whether the per-stratum count came from the + YAML `sample_per_stratum` or the CLI `--sample-per-stratum` flag. That is intentional: the + nightly activation suite relies on it to broaden coverage across runs rather than re-measuring + the same rows forever. + + Set `sample_seed` to an integer to pin a reproducible sample — do that when you are comparing + two runs and need the row set held constant (a regression bisect, an A/B where you want the + identical rows in both arms on separate invocations). Leave it unset for recurring suites where + coverage over time matters more than run-to-run comparability. Note the contrast with + `--sample N`, which is fixed-seed and reproducible by default. + +## Suite-level scoring + +Per-row pass/fail is rarely the number you care about on a dataset — the suite metric is. Every +criterion's `aggregate()` emits `count / mean / median / std / min / max`, and classification-style +criteria (`classification_match`, `skill_triggered`) additionally emit accuracy, per-label +precision / recall / F1, and a confusion matrix. + +Add `suite_thresholds` to a criterion to gate the whole suite on those aggregated metrics; the run +exits non-zero if any gate fails: + +```yaml +success_criteria: + - type: "classification_match" + path: "result.txt" + expected_label: "${row.expected}" + allowed_labels: [positive, negative] + description: "Predicted label matches expected" + suite_thresholds: + accuracy: 0.8 + macro_f1: 0.75 +``` + +Full metric-key list and the rollup outputs: +[User Guide → Suite Thresholds](USER_GUIDE.md#suite-thresholds--classification-metrics) and +[Task Definition Guide → `skill_triggered`](TASK_DEFINITION_GUIDE.md#skill_triggered). + +## Worked example: inline rows + +`tasks/dataset_example.yaml` — two rows, one criterion, expands to +`dataset-example/alpha` and `dataset-example/beta`: + +```yaml +task_id: "dataset-example" +description: "Demonstrates dataset fan-out: one task file, N sub-tasks, one per row." +initial_prompt: "Write the string '${row.expected}' into out.txt. Say nothing else." +tags: [smoke, smoke-pass, dataset] + +agent: + type: "claude-code" + permission_mode: "acceptEdits" + allowed_tools: ["Write"] + +dataset: + rows: + - id: alpha + expected: "alpha" + - id: beta + expected: "beta" + +success_criteria: + - type: "file_check" + path: "out.txt" + includes: ["${row.expected}"] + description: "out.txt contains '${row.expected}'" +``` + +## Worked example: JSONL plus suite thresholds + +`tasks/sentiment_classification.yaml` reads its rows from a JSONL file and gates the suite on +aggregated classification metrics: + +```yaml +task_id: "sentiment-classification" +description: "Binary sentiment classification over a 3-row dataset with one intentionally mislabeled ground truth, to exercise non-trivial P/R/F1." +tags: [classification] + +agent: + type: "claude-code" + permission_mode: "acceptEdits" + allowed_tools: ["Write"] + +# Row 3 is intentionally mislabeled (text is clearly positive but expected=negative). +# A correct model produces: accuracy 2/3, recall(positive)=1/2, recall(negative)=1/1, +# precision(positive)=1/1, precision(negative)=1/2, f1(positive)=f1(negative)=2/3. +dataset: + paths: + - "datasets/sentiment.jsonl" + +initial_prompt: | + Classify the sentiment of the following text as exactly one word: 'positive' or 'negative'. + Write only that single word (lowercase, no punctuation, no other text) to a file named result.txt. + + Text: "${row.text}" + +success_criteria: + - type: "classification_match" + path: "result.txt" + expected_label: "${row.expected}" + allowed_labels: [positive, negative] + description: "Predicted label matches expected for row ${row.id}" + # Suite-level thresholds: evaluated against the aggregated metrics. + # With the one intentionally mislabeled row the expected numbers are + # accuracy=0.667, macro_f1=0.667, recall.positive=1.0. + suite_thresholds: + accuracy: 0.6 + macro_f1: 0.5 + recall.positive: 0.9 +``` + +Its rows live in `tasks/datasets/sentiment.jsonl`: + +```json +{"id": "r1", "text": "This product is fantastic, I love it.", "expected": "positive"} +{"id": "r2", "text": "Terrible service, would not recommend.", "expected": "negative"} +{"id": "r3", "text": "Amazing quality and excellent value for money.", "expected": "negative"} +``` + +Run it, or a cheap sample of it: + +```bash +coder-eval run tasks/sentiment_classification.yaml # every row +coder-eval run tasks/sentiment_classification.yaml --sample 2 # a reproducible 2-row sample +``` + +## See also + +- [Task Definition Guide](TASK_DEFINITION_GUIDE.md#dataset) — the `dataset:` field reference. +- [Report Schema](REPORT_SCHEMA.md) — the `suite.json` field contract. +- [A/B Experiments](AB_EXPERIMENTS.md) — running a dataset across multiple variants. diff --git a/docs/DIALOG_MODE.md b/docs/DIALOG_MODE.md new file mode 100644 index 00000000..e9697a82 --- /dev/null +++ b/docs/DIALOG_MODE.md @@ -0,0 +1,237 @@ +--- +description: >- + Evaluate coding agents in conversation, not one-shot — Coder Eval's dialog + mode drives a simulated user with a persona and a goal, so you can measure + clarifying questions, requirement discovery, and multi-turn recovery. +--- + +# Dialog Mode: multi-turn user simulation + +Most evaluation harnesses hand an agent one perfect prompt and grade the artifact. Real users don't +write perfect prompts. They ask for something vague, answer questions, change their mind, and hold +back requirements until asked. + +Dialog mode replaces the single-shot loop with a conversation between the coding agent and a +**simulated user** — a second LLM driven by a persona and a goal. It is the only way to measure the +behaviors that only exist in conversation. + +## When to use it + +Reach for dialog mode when the thing you want to score is conversational: + +- **Does the agent ask?** A good agent asks a clarifying question instead of guessing when the + request is underspecified. Give the simulator a goal that withholds a requirement and see whether + the agent surfaces it. +- **Requirement discovery.** Score whether the agent eventually extracts everything it needs across + several turns, not whether it one-shots a fully-specified prompt. +- **Multi-turn recovery.** The agent goes down the wrong path, the user corrects it — does it + recover, or double down? +- **Prompt robustness.** A persona that is terse, impatient, or non-technical is a much harsher test + than your hand-tuned `initial_prompt`. + +Stay in single-shot mode when the task is genuinely fire-and-forget. Dialog mode multiplies cost and +adds variance; don't pay for it when the extra turns measure nothing. + +## How it works + +Add an enabled `simulation:` block to any task and the orchestrator swaps its iteration loop for a +dialog driver: + +```yaml +simulation: + enabled: true + persona: | + You are a user with one specific request. You give the request only when + asked, and then you wait for the agent to do it. + goal: | + Get the agent to echo your string back verbatim. Reveal the string only + when the agent asks what you want. + max_turns: 4 +``` + +The mechanics: + +- The task's `initial_prompt` is the user's **opening message**. The simulator takes over from turn + two, seeing the agent's last reply and producing the next user utterance. +- `initial_prompt` is **optional** in dialog mode. Omit it and the simulator writes the opener too, + from its persona and goal — closer to a cold-start user. +- Each exchange is one user message plus the agent's full response (the agent may make many tool + calls inside a single exchange). +- The simulator is a **tools-disabled Claude Code agent** with `allowed_tools: []`, an explicit + deny-list, and no plugins or settings sources. It is pure text-in / text-out, and it **cannot see + the sandbox** — no files, no terminal, no agent reasoning. Only what the agent writes in the chat. +- The simulator shares the coding agent's resolved `ApiRoute`, so backend and model come from the + run's routing (`--backend direct` / `--backend bedrock`) rather than from the `simulation:` block. + There is no model field here to set. + +**Agent-kind constraint.** The *subject* agent can be any registered kind — the dialog driver only +calls the agent's `communicate()`, so Codex and plugin agents work. The *simulator*, however, is +always a Claude Code agent. A dialog run therefore needs the `claude` CLI on `PATH` and working +Anthropic or Bedrock credentials **even when the agent under test is not Claude**. + +**Early stop is not available here.** [`run_limits.stop_early`](TASK_DEFINITION_GUIDE.md#stop_early-opt-in-early-stop) +is single-shot-only; arming it alongside `simulation.enabled` is a hard error at resolution time, not +a silent no-op. Use `stop_on_criteria_pass` (below) for the dialog equivalent. + +Every field's default and constraint lives in one place — the +[Task Definition Guide's simulation section](TASK_DEFINITION_GUIDE.md#simulation-multi-turn-user-dialog). +This page deliberately does not restate them. + +## Designing the persona and goal + +`persona` is *who is talking*; `goal` is *what they want*. Keep them separate — a persona that +smuggles in the requirements defeats the point. + +The most useful pattern is **withholding**. State the full goal for the simulator, then instruct it +not to volunteer part of it: + +```yaml +persona: | + A non-technical business analyst who knows the outcome they want but not how + automation works. Mildly impatient, answers questions directly. +goal: | + Build a flow that reads invoice PDFs from an Outlook folder, extracts + vendor/amount/date, and posts the result to Google Sheets. + Do NOT volunteer the Google Sheets requirement unless the agent asks where + the output should go. +constraints: + - "Do not paste code — you cannot read code." + - "If the agent goes silent for two turns, ask 'are you still there?'." +``` + +Now the task measures something a single-shot prompt cannot: whether the agent asks about the output +destination at all. Pair it with a criterion that checks the Sheets integration exists and you have a +clean signal. + +`constraints` are behavioral rules, not more requirements. Good ones bound *how* the simulator +speaks ("no code", "one question at a time", "reveal X only if asked"). Resist writing constraints +that quietly hand the agent the answer. + +## How a dialog ends + +After each exchange the driver evaluates the stop conditions **in this order**, first match wins: + +1. **`run_limits` breach** (`run_limit_exceeded`) — a subject-agent budget cap tripped. The + end-of-dialog criteria still run for partial credit, then the task **aborts**. +2. **`stop_on_criteria_pass`** (`criteria_passed`) — every success criterion passes. Requires + per-turn checking (`check_criteria: every_turn` or `both`); pairing it with the default + `end_of_dialog` is rejected at load time, since there would be nothing to check against. +3. **`max_turns`** (`max_turns`) — the hard cap on exchanges. The agent exhausting its *own* inner + `max_turns` mid-exchange ends the dialog with the same reason. +4. **`max_total_tokens`** (`budget`) — the dialog-wide budget across simulator **and** agent. The + dialog ends and the task is **still scored** — unlike + [`run_limits.max_total_tokens`](TASK_DEFINITION_GUIDE.md#run-limits), which covers the subject + agent only and aborts. +5. **`stop_token`** (`stop_token`) — only if none of the above fired is the simulator asked for + another message; the sentinel token in *that fresh utterance* ends the dialog. This is the + workhorse in practice — the simulator decides, in character, that it got what it wanted — but it + is evaluated **last**, so a turn that trips `max_turns` or the budget never gets the chance to + produce it. + +A simulator call that raises ends the dialog with `error` (and increments +`simulation.simulator_failures`). The reason is recorded as `simulation.stop_reason` on the result, +so you can tell a conversation that finished from one that merely ran out of turns. + +> **`check_criteria: every_turn` multiplies criterion cost.** Criteria are re-evaluated after every +> exchange, so an `llm_judge` or `agent_judge` criterion in a 12-turn dialog runs twelve times. +> That is often more expensive than the dialog itself. Use `end_of_dialog` (the default) unless you +> genuinely need early stop or a per-turn record; `both` gives you the per-turn trace *and* an +> authoritative final check. + +## Trials and variance + +A single dialog is one sample from a stochastic process — the simulator improvises as much as the +agent does. `n_trials` runs the conversation N independent times per (task, variant): + +```yaml +simulation: + enabled: true + n_trials: 5 +``` + +Each trial is fanned out as its own unit of work with its own sandbox, its own run directory +(`runs/////`, zero-padded index), and its own `task.json`. On the result, +`simulation.replicate_index` and `simulation.n_trials` identify the trial, and reports render a +"Trial *k* of *N*" row. Trials reuse the replicate machinery, so when a simulation task sets +`n_trials > 1` it takes precedence over experiment-level `repeats`. + +Trials are ordinary batch units: they run concurrently exactly as far as `--max-parallel` allows — +there is no per-task trial-concurrency switch; scheduling is entirely `--max-parallel`'s job. + +## Grading a dialog + +Set `include_dialog: true` on every `llm_judge` / `agent_judge` criterion in a simulated task. This +is not optional polish — without it the judge sees only the agent's outputs and no user side, so a +requirement the user stated in turn 3 looks like something the agent invented. Judges routinely +score that as a hallucination. + +```yaml +success_criteria: + - type: llm_judge + description: "Agent satisfied the requirement the user gave mid-conversation" + prompt: | + Read the DIALOG block. Grade the agent on whether it satisfied the + requirement the user actually stated — not what you assume the task is. + include_dialog: true + pass_threshold: 0.9 +``` + +The rendered dialog is wrapped as `UNTRUSTED DATA` with a rubric guard telling the judge that claims +made only by the *simulated user* may be invented, and not to penalize the agent for going along +with them. That guard is what keeps a chatty simulator from poisoning the grade. + +## What it costs + +Budget roughly `max_turns × n_trials` agent turns per (task, variant) — the worst case, since the +dialog usually stops on the stop token first. On top of that: + +- **Simulator tokens**, one generation per turn. Small next to the agent's, but not free, and they + are reported separately as `simulation.simulator_input_tokens` / `simulator_output_tokens`. +- **Criterion cost**, multiplied by turn count under `check_criteria: every_turn` or `both`. + +Cap it with [`run_limits`](TASK_DEFINITION_GUIDE.md#run-limits) — but note the accounting boundary: +`run_limits` budgets count the **subject agent only**, so simulator spend is invisible to +`max_total_tokens` and `max_usd`. To bound the conversation as a whole, use +`simulation.max_total_tokens`, which counts both sides. + +## Dialog mode as an experiment axis + +Everything on the `simulation:` block is overridable per variant, which makes the conversation itself +the thing under test. A variant sets a partial `simulation:` block that shallow-merges onto the +task's: + +```yaml +variants: + - variant_id: patient-user + simulation: + persona: "A patient engineer who answers questions precisely." + - variant_id: terse-user + simulation: + persona: "A terse, impatient user who gives one-line answers." +``` + +Productive axes: persona (terse vs. chatty, technical vs. not), how much the goal withholds, and +`n_trials` as a cost/confidence trade. See [A/B Experiments](AB_EXPERIMENTS.md) for the full +experiment layer. + +## Security posture + +The task's `reference` solution is **never** passed to the simulator, exactly as it is never passed +to the coding agent. A simulator that could read the reference would leak it into the dialog and the +agent would be graded against an answer it was handed. + +What the simulator *does* see, all inlined into its system prompt: `persona`, `goal`, `constraints`, +the task's **`description`**, and the pinned `initial_prompt` when there is one. The `description` is +easy to forget — it reads like evaluator-private metadata, but the simulator gets it verbatim. If +your persona withholds a requirement, don't spell that requirement out in `description`, or the +simulator may volunteer what you meant it to keep back. + +The simulator also runs with no tools, no plugins, and no settings sources, so it cannot read or +write the sandbox even if its persona instructs it to. + +## See also + +- [Task Definition Guide → Simulation](TASK_DEFINITION_GUIDE.md#simulation-multi-turn-user-dialog) — + every field, default, and constraint. +- [A/B Experiments](AB_EXPERIMENTS.md) — running personas as variants. +- [Run Limits](TASK_DEFINITION_GUIDE.md#run-limits) — the caps that bound a dialog's cost. diff --git a/docs/DOCKER_ISOLATION.md b/docs/DOCKER_ISOLATION.md index 6deba4a5..d3076229 100644 --- a/docs/DOCKER_ISOLATION.md +++ b/docs/DOCKER_ISOLATION.md @@ -1,6 +1,6 @@ --- description: >- - Run each coder_eval task in its own fresh Docker container — strong host + Run each Coder Eval task in its own fresh Docker container — strong host isolation, a pinned reproducible agent runtime, and custom images for task-specific dependencies. --- @@ -51,6 +51,63 @@ sandbox: image: my-custom:tag # override the default image ``` +## Using a pre-built custom image + +When your tasks need extra tools or dependencies, extend the framework image once and point tasks at +the result — no per-task build. The custom image must extend `coder-eval-agent:` so it +inherits the coder-eval runtime, the entrypoint script, and the `org.coder-eval.version` label the +host's preflight check reads. (For a task whose Dockerfile can't be rebased onto Debian, use +[the runtime kit](#tasks-that-bring-their-own-base-image-the-runtime-kit-coder-eval-runtime) +instead. To have coder-eval build the image per task rather than pre-building it, see +[Building the image from a task Dockerfile](#building-the-image-from-a-task-dockerfile).) + +```dockerfile +FROM coder-eval-agent: # match the version your host runs +RUN apt-get update && apt-get install -y --no-install-recommends custom-tool \ + && rm -rf /var/lib/apt/lists/* +``` + +```bash +docker build -t my-team/image:latest . +``` + +Select it either in the task YAML: + +```yaml +sandbox: + driver: docker + docker: + image: my-team/image:latest +``` + +…or from the CLI, which overrides whatever the YAML says: + +```bash +coder-eval run task.yaml -D sandbox.docker.image=my-team/image:latest +``` + +The default when you set nothing is `coder-eval-agent:`. + +A worked example ships in-tree: `tasks/byod_smoke_test.yaml` runs against +`templates/byod_smoke_test/Dockerfile`, which extends the framework image and drops a marker file +that the task's success criterion then asserts — proving the custom image was actually used. (The +`byod_*` names here mean "Bring Your Own **Docker**"; they are unrelated to the +[Bring Your Own Dataset](DATASETS.md) guide, which is about fanning one task out over data rows.) + +```bash +make docker-image # base image first +docker build -t byod-custom-image:0.1.0 templates/byod_smoke_test/ # then the derived one +coder-eval run tasks/byod_smoke_test.yaml +``` + +### Troubleshooting custom images + +| Symptom | Cause and fix | +| --- | --- | +| `docker: Error response from daemon: pull access denied` | The image isn't built locally and isn't pullable. Check `docker images`, then rebuild it. Docker treats an unknown local tag as a remote reference, which is why the error mentions a pull. | +| `Image coder_eval != host ` | The custom image carries an `org.coder-eval.version` label inherited from a stale framework base. Rebuild the base with `make docker-image`, then rebuild your derived image with `docker build --no-cache`. | +| `Image has no org.coder-eval.version label` | The image doesn't descend from `coder-eval-agent` (or predates the label). Rebase it on the framework image, or use the runtime kit. | + ## Building the image from a task Dockerfile Instead of pointing at a pre-built `image`, a task can ship its own `Dockerfile` diff --git a/docs/EXTENDING.md b/docs/EXTENDING.md new file mode 100644 index 00000000..c0eb1693 --- /dev/null +++ b/docs/EXTENDING.md @@ -0,0 +1,239 @@ +--- +description: >- + Extend Coder Eval with a custom agent, a custom success criterion, or model + pricing — the plugin SPI (coder_eval.plugins entry-point group), the Agent ABC + checklist, the @register_criterion decorator, and register_pricing. +--- + +# Extending Coder Eval + +Coder Eval is extensible along three seams, all designed so a third party can add +capability **without editing the base package**: + +1. **Agents** — register a new `agent.type` via the plugin SPI. +2. **Criteria** — add a new success-criterion type via a decorator + auto-discovery. +3. **Pricing** — contribute USD rates for models your plugin runs. + +This guide covers all three. For the internal architecture notes see +[CLAUDE.md](https://github.com/UiPath/coder_eval/blob/main/CLAUDE.md). + +--- + +## 1. Custom agents (the plugin SPI) + +Agents register through the **`coder_eval.plugins` entry-point group** — there is no +closed enum or dispatch to edit. `agent.type` is an open string validated against +the `AgentRegistry`; built-in and third-party agents travel the exact same path. + +### Wire up the entry point + +In your plugin package's `pyproject.toml`: + +```toml +[project.entry-points."coder_eval.plugins"] +my_plugin = "my_plugin:register" +``` + +At CLI init, `load_plugins()` imports each entry point and calls it with the +`AgentRegistry` **class** (not an instance). A third-party hook that raises is logged +and skipped; only a failing *built-in* registration is fatal. + +### The `register` hook + +```python +from coder_eval.agents.registry import AgentRegistry + +def register(registry: type[AgentRegistry]) -> None: + # Bind type string → config class → agent class. + registry.register("my-agent", MyAgentConfig)(MyAgent) + # Optionally contribute pricing here too (see §3): + # register_pricing(MY_RATES) +``` + +`AgentRegistry.register(agent_kind, config_class)` returns a decorator, so the +decorator form works too: + +```python +@AgentRegistry.register("my-agent", MyAgentConfig) +class MyAgent(Agent[MyAgentConfig]): + ... +``` + +Registration is **anti-shadow**: re-registering the same `(agent_class, +config_class)` pair is a no-op, but claiming an existing `agent.type` with a +*different* implementation raises `ValueError`. Two plugins can never silently fight +over one type. + +### The config class + +Subclass `BaseAgentConfig` with your own `type` discriminator: + +```python +from typing import Literal +from coder_eval.models import BaseAgentConfig # importable from coder_eval.models + +class MyAgentConfig(BaseAgentConfig): + type: Literal["my-agent"] = "my-agent" + my_option: str = "default" +``` + +The factory `create_agent(kind, config, …)` raises `TypeError` if the passed config +isn't an instance of the registered `config_class`, so keep them paired. + +### The `Agent` ABC — implementation checklist + +Implement these three abstract methods: + +- [ ] `async def start(self, working_directory, *, env_path_prepend=None, plugin_tools_dir=None) -> None` +- [ ] `async def communicate(self, user_input, *, stream_callback=None, timeout=None, max_turns=None, should_stop=None) -> TurnRecord` +- [ ] `async def stop(self) -> None` + +Optional overrides (sensible defaults exist): `kill()`, `kill_sync()` (called from a +non-asyncio watchdog thread — must **not** await), `discard_pending_turn()`. + +Follow the shared turn lifecycle (do **not** hand-assemble a `TurnRecord`): + +- [ ] Call `self._begin_turn()` at the top of `communicate()`. +- [ ] Call `self._end_turn_ok()` on the success path. +- [ ] Call `self._mark_stopped()` in `stop()` after your own teardown. +- [ ] Before raising on a mid-turn failure, set `self.pending_turn` to a + `crashed=True` `TurnRecord` (built from an `EventCollector`), then raise + `AgentCrashError` / `TurnTimeoutError` (bare — no payload). The orchestrator + drains it and calls `discard_pending_turn()`. + +Emit the standardized event protocol (you are the **sole emitter**): one +`AgentStartEvent` at the top of `communicate()` and one matching `AgentEndEvent` on +**every** exit path (emit from `finally`), a `TurnStart`/`TurnEnd` pair per inner +turn, and `ToolStart`/`ToolEnd` per tool call (close orphaned tools with +`status=unresolved`). Fan events through an internal `EventCollector` — it builds the +returned `TurnRecord`, the single agent-agnostic capture path. + +Set `supports_cooperative_stop: ClassVar[bool] = True` only if your `communicate()` +actually honors `should_stop` (needed for `run_limits.stop_early`). Leaving it +`False` means early stop is rejected at resolution for your agent — which is correct +if you can't stop cooperatively. + +### Worked example + +The in-tree worked example is the BYOA test fixture at +[`tests/fixtures/byoa_demo_plugin/`](https://github.com/UiPath/coder_eval/tree/main/tests/fixtures/byoa_demo_plugin) +— a minimal package with a `register` hook and the `coder_eval.plugins` entry point. +(It subclasses `ClaudeCodeAgent` for brevity; a real third-party agent implements the +`Agent` ABC from scratch.) + +--- + +## 2. Custom success criteria + +Criteria are discovered automatically by `pkgutil` scan of the `coder_eval.criteria` +package — a new checker just needs the `@register_criterion` decorator. Adding one is +two steps: a Pydantic **model** (the YAML schema) and a **checker** (the logic). + +### Step 1 — the model + +In `models/criteria.py`, subclass `BaseSuccessCriterion` and set the discriminator as +a `Literal` default, then add it to the `SuccessCriterion` union: + +```python +class MyCriterion(BaseSuccessCriterion): + type: Literal["my_criterion"] = "my_criterion" + target: str + # Set requires_agent = True (ClassVar) if you read turn_records. + +# ...and add `| MyCriterion` to the SuccessCriterion discriminated union. +``` + +Union membership is required — a run validates that every union member's `type` has a +registered checker, and rejects unknown `type` tags in YAML. `BaseSuccessCriterion` +gives you `description`, `weight` (default 1.0; `0` = informational/non-gating), +`pass_threshold` (default 0.9), `stop_when`, and `suite_thresholds` for free, with +`extra="forbid"` so YAML typos are caught. + +### Step 2 — the checker + +Drop a file in `coder_eval/criteria/` with a `@register_criterion` class: + +```python +from coder_eval.criteria.base import BaseCriterion, register_criterion +from coder_eval.models import CriterionResult + +@register_criterion +class MyChecker(BaseCriterion[MyCriterion]): + criterion_type = "my_criterion" # must match the model discriminator + + def _check_impl(self, criterion, sandbox, reference_code=None, *, + turn_records=None, context=None) -> CriterionResult: + ok = ... # your logic + return CriterionResult( + criterion_type=self.criterion_type, + description=criterion.description, + score=1.0 if ok else 0.0, + details="...", + ) +``` + +Notes: + +- **Do not override `check()`** — it's final and wraps `_check_impl` with error + handling (an exception becomes a score-0.0 result with the error captured). +- Return `score` in `[0.0, 1.0]` — binary criteria use `0.0`/`1.0`; fractional ones + anything in between. +- For **suite-level metrics** on dataset-backed tasks, override + `aggregate(criterion, per_row_results)`; the base emits + `count/mean/median/std/min/max`, so your criterion is suite-thresholdable for free. + Classification-style criteria return a `ClassificationCriterionResult` and layer + accuracy / precision / recall / F1 / confusion on top. +- For **early stop**, implement `live_verdict(...)` and declare + `live_stop_polarities` — a lint rule keeps the two consistent. + +> A duplicate `criterion_type` **overwrites** the earlier checker with a warning (not +> a hard error, unlike agents) — keep type strings unique. + +--- + +## 3. Model pricing + +Plugins that run their own models contribute USD rates through `register_pricing` — +there is **no** separate entry-point group; call it from the same `register()` hook. + +```python +from coder_eval.pricing import ModelPricing, register_pricing + +# Rates are per MILLION tokens: (input, output, cache_write, cache_read) +MY_RATES = { + "my-model-v1": ModelPricing(3.0, 15.0, 3.75, 0.30), + "my-free-model": ModelPricing(0.0, 0.0, 0.0, 0.0), # a valid free-model entry +} + +def register(registry): + registry.register("my-agent", MyAgentConfig)(MyAgent) + register_pricing(MY_RATES) +``` + +Behavior: + +- **Keys** are the bare model id as it appears in `agent.model` (vendor/Bedrock + region prefixes like `eu.` / `anthropic.` are normalized off at lookup). +- **Idempotent** for identical rates; **raises `ValueError`** on a *conflicting* rate + for an existing key (built-in or another plugin). Registration is all-or-nothing — + a late conflict leaves nothing half-applied. +- **Zero rates are valid** (a genuinely free model) — the lookup uses "is a rate + present", not truthiness, so an all-zero entry prices to `0.0` rather than falling + through to the built-in table. +- The plugin overlay is consulted **before** the built-in table, so every consumer + (agents, reports, the cost simulator) prices your model transparently via + `calculate_cost(model, uncached_input, output, cache_creation=0, cache_read=0)`. + (Pass `uncached_input`, not the total input.) + +The base package ships **no** plugin rates; only the built-in table. + +--- + +## See also + +- [Claude Code](agents/CLAUDE_CODE.md) · [Codex](agents/CODEX.md) · + [Antigravity](agents/ANTIGRAVITY.md) — the built-in agents, each registered via + this same SPI +- [Task Definition Guide](TASK_DEFINITION_GUIDE.md) — the criterion catalogue +- [CLAUDE.md](https://github.com/UiPath/coder_eval/blob/main/CLAUDE.md) — architecture + and extension points in depth diff --git a/docs/IDEAS.md b/docs/IDEAS.md deleted file mode 100644 index a9e7a018..00000000 --- a/docs/IDEAS.md +++ /dev/null @@ -1,459 +0,0 @@ -# Ideas & Backlog - -A lightweight, stack-ranked list of improvements for `coder_eval` (the eval harness, -the dashboard/CI, and the evalboard UI). Anyone can add an idea — keep it short, then -we triage and work the list top-down. - -## How to stack rank - -**Rank lives in one place: row order in the table below. Top row = do first.** -To re-rank, just move a row up or down — nothing else changes. - -- Each idea has a **stable ID** (e.g. `expected-turns`) that never changes, even when - the rank does. Headings and cross-references use the ID, not a position number, so - reordering never means renumbering. -- To decide *where* a row goes, score it on **Impact** and **Effort** (1–5 each), then - let **Score** rank it: - - > **Score = Impact + (6 − Effort)** — range 2–10, higher floats up. Sort by Score; - > break ties with judgment (dependencies, momentum, who's free). - - - **Impact** = how much this helps the *skills people are building* or our - organization's *ability to improve* them. Bigger lever = higher. - - **Effort** = order-of-magnitude **tokens a coding agent burns to build it** - (best guess is fine — it's a log scale, so just pick the nearest power of ten). - -| Field | 1 | 2 | 3 | 4 | 5 | -|-------|---|---|---|---|---| -| **Impact** | trivial | minor | useful | important | game-changing | -| **Effort** (tokens) | ~100K | ~1M | ~10M | ~100M | ~1B | - -**Status:** 💡 idea · 🔍 scoping · 🚧 in progress · ✅ done · ❄️ parked - -## Backlog - -| ID | Idea | Impact | Effort | Score | Status | Owner | -|----|------|:------:|:------:|:-----:|:------:|-------| -| [`expected-turns`](#expected-turns) | Top-level Expected Turns score | 4 | 2 | 8 | 🚧 | Sherif | -| [`efficiency-columns`](#efficiency-columns) | Surface already-computed efficiency metrics on the board | 4 | 2 | 8 | 💡 | — | -| [`config-fingerprint`](#config-fingerprint) | Per-run config fingerprint + regression attribution | 4 | 2 | 8 | 💡 | — | -| [`judge-ensemble`](#judge-ensemble) | Multi-sample judge voting + verdict variance | 4 | 2 | 8 | 💡 | — | -| [`cost-preflight`](#cost-preflight) | Pre-flight budget validation + judge cost estimate | 3 | 1 | 8 | 💡 | — | -| [`long-failing`](#long-failing) | PR a fix for long-running failed tests | 4 | 3 | 7 | 💡 | — | -| [`failure-mode-rollup`](#failure-mode-rollup) | Failure-mode taxonomy headline | 4 | 3 | 7 | 💡 | — | -| [`judge-calibration`](#judge-calibration) | Golden-set judge calibration + drift detection | 4 | 3 | 7 | 💡 | — | -| [`result-cache`](#result-cache) | Skip-unchanged task result cache | 4 | 3 | 7 | 💡 | — | -| [`smart-judge-truncation`](#smart-judge-truncation) | Smarter judge context truncation | 3 | 2 | 7 | 💡 | — | -| [`per-criterion-telemetry`](#per-criterion-telemetry) | Per-criterion timing + token telemetry | 3 | 2 | 7 | 💡 | — | -| [`top-offenders`](#top-offenders) | Top Offenders page | 3 | 3 | 6 | 💡 | — | -| [`diff-criterion`](#diff-criterion) | Diff/patch validation criterion | 3 | 3 | 6 | 💡 | — | -| [`ux-revamp`](#ux-revamp) | UX revamp | 4 | 4 | 6 | 💡 | — | - ---- - -### expected-turns -**Top-level Expected Turns score** - -**What:** Roll today's *per-task* expected-turns signal up into a single headline -metric for a run (e.g. "% of tasks within expected turns", or mean turn overage), -surfaced on the dashboard and in the nightly Slack summary alongside pass-rate. - -**Why:** A task can pass but take far more turns than budgeted — a real efficiency -regression that the pass/fail headline hides. We already compute the per-task signal; -we just don't aggregate or surface it at the top. - -**What exists today:** -- `expected_turns_overage(result)` and `visible_turn_count(result)` — - [src/coder_eval/reports_stats.py:174](../src/coder_eval/reports_stats.py#L174) -- Per-task "expected_turns exceeded" badge in the HTML report — - [src/coder_eval/reports_html.py:341](../src/coder_eval/reports_html.py#L341) -- `run_limits.expected_turns` is the per-task budget source. - -**Rough approach:** aggregate overage across a run → add a field to the run summary → -render on the evalboard + add one line to the nightly Slack summary. - -**Open questions:** -- What's the right headline statistic — % within budget, mean overage, or count exceeded? -- Per-skill breakdown too, or run-level only to start? - ---- - -### long-failing -**PR a fix for long-running failed tests** - -**What:** Identify eval tasks/tests that consistently run long *and* fail (burning the -nightly's 4h budget for no signal), then open PRs to fix or quarantine them. - -**Why:** Long failing tasks eat wall-clock and cost on every nightly run and add noise. -Tightening or fixing them improves both run time and the trustworthiness of the headline. - -**Rough approach:** mine recent `runs/` for tasks with high duration + non-SUCCESS status → -rank worst offenders → per-task, either fix the underlying issue or adjust limits/quarantine → -PR each fix. - -**Open questions:** -- Threshold for "long-running" — absolute seconds, or top-N by duration? -- Fix vs. quarantine vs. limit-tuning — decide per task or set a default policy? -- One-off cleanup, or a recurring report we act on each cycle? - ---- - -### top-offenders -**Top Offenders page** - -**What:** A single page that ranks the things most worth addressing first — worst -tasks/skills by failure rate, turn overage, duration, and cost — so triage starts from -"here's what to fix" instead of scrolling a full run. - -**Why:** Today you read the whole board to find problems. A focused "top offenders" -view turns the metrics we collect (and the ones in `expected-turns` and `long-failing`) -into an actionable work queue, and gives this backlog a data-driven feed. - -**Rough approach:** aggregate per-task/per-skill stats across the latest run (reuse the -signals from [reports_stats.py](../src/coder_eval/reports_stats.py)) → rank by a few axes → -render as a sortable list on the evalboard, linking each row to its task detail. - -**Open questions:** -- Which axes and what default sort — failures first, or a blended "needs-attention" score? -- Latest-run only, or trend across recent runs to catch persistent offenders? -- Standalone page or a panel on the run overview? (overlaps with `ux-revamp`) - ---- - -### ux-revamp -**UX revamp** - -**What:** Refresh the evalboard UI ([evalboard/](../evalboard/), Next.js) — clearer run -overview, easier drill-down from suite → skill → task, better surfacing of the metrics -above (pass-rate, cost, turns). - -**Why:** The board is the daily touchpoint for reading nightly results; a clearer UX -makes regressions and outliers faster to spot. - -**Open questions (needs scoping before it's actionable):** -- What are the top 2–3 jobs-to-be-done on the board today that are painful? -- Incremental polish vs. a rethink of the information architecture? -- Any design input, or engineer's-judgment pass? - ---- - -### efficiency-columns -**Surface already-computed efficiency metrics on the board** - -**What:** We already compute per-task efficiency signals and write them into -`run.json`, but the evalboard never shows them. Add columns/badges for -`commands_efficiency`, a `max_turns_exhausted` status badge, total tokens, and a -derived cache-hit-rate (`cache_read / (cache_creation + cache_read)`) — and show -`expected_commands` next to actual so over-budget tasks are obvious at a glance. - -**Why:** Cost and efficiency regressions are invisible today. The data is *already -in the artifact* — this is pure surfacing, not new computation, so it's a cheap, -high-leverage win that complements [`expected-turns`](#expected-turns). - -**What exists today:** -- `eval_result_to_task_dict()` writes `commands_efficiency`, `max_turns_exhausted`, - `expected_commands`, and `total_tokens` into each task dict — - [src/coder_eval/reports_experiment.py:42](../src/coder_eval/reports_experiment.py#L42) -- The grid recomputes/displays only a subset (turns, cost, raw token buckets) — - [evalboard/app/runs/[id]/task-grid.tsx](../evalboard/app/runs/[id]/task-grid.tsx) -- Turn-budget coloring already exists as a model — [evalboard/lib/turns.ts](../evalboard/lib/turns.ts) - -**Rough approach:** widen the evalboard `RawTaskResult` type to read the existing -fields → add sortable columns + a `max_turns_exhausted` badge → reuse the -`turns.ts` color-ratio pattern for an efficiency/cache cell. - -**Open questions:** -- Which metrics earn a column vs. a hover/detail-panel stat (avoid grid sprawl)? -- Cache-hit-rate: compute in the loader, or add it to `run.json` once so the - Slack summary can use it too? - ---- - -### config-fingerprint -**Per-run config fingerprint + regression attribution** - -**What:** Stamp each run with a stable fingerprint of the inputs that actually drive -results (resolved task config + agent/model + skills/cli SHAs + framework version), -and add a board view that diffs two runs' fingerprints. When pass-rate moves, -answer "did the model change, the prompt change, or the task config change?" - -**Why:** Today a regression between nightlies is a whodunit — you can't separate a -model bump from a prompt rewrite from a config drift. The raw material (config -lineage, component SHAs) is already captured per task; nothing ties it into a -run-level, diffable fingerprint. - -**What exists today:** -- Per-task `task_config` + dotted-path config lineage — - [src/coder_eval/reports_experiment.py:42](../src/coder_eval/reports_experiment.py#L42) -- Component SHAs (coder_eval / skills / cli) already in the run summary and Slack - footer — the nightly Slack summary -- Single declarative merge resolver produces the resolved config — - [src/coder_eval/orchestration/config_merge.py](../src/coder_eval/orchestration/config_merge.py) - -**Rough approach:** hash the resolved-config + SHA bundle into `run.json` → add a -"compare runs" panel on the evalboard that renders the field-level diff → optionally -flag pass-rate deltas whose fingerprint is *identical* (i.e. pure agent nondeterminism). - -**Open questions:** -- Fingerprint at run level, per-skill, or per-task (tasks can differ within a run)? -- What's the canonical diff unit — resolved YAML, or the lineage entries? - ---- - -### judge-ensemble -**Multi-sample judge voting + verdict variance** - -**What:** Let `llm_judge` (and optionally `agent_judge`) run N independent samples -per row and aggregate — median/mean score plus a variance/disagreement signal — -instead of trusting a single one-shot verdict. Surface verdict spread so a -"flaky judge" is visible rather than silently noisy. - -**Why:** Subjective scoring underpins a large share of tasks, but every judged score -today is a single sample at temperature 0 with no measure of its own reliability. A -judge that would score the same artifact 0.4 / 0.8 / 0.6 looks authoritative. Voting -reduces variance; reporting spread tells us *which rubrics are unreliable*. - -**What exists today:** -- Single-sample verdict via forced `submit_verdict` tool call; temperature frozen at - parse time — [src/coder_eval/criteria/llm_judge.py](../src/coder_eval/criteria/llm_judge.py) -- `JudgeVerdict` (score / rationale / findings) with score clamped to [0,1] — - [src/coder_eval/models/judge.py](../src/coder_eval/models/judge.py) -- Per-criterion `aggregate()` already exists for suite rollups — - [src/coder_eval/criteria/base.py:192](../src/coder_eval/criteria/base.py#L192) - -**Rough approach:** add `samples: int` (+ aggregation rule) to the judge criteria → -run N calls (concurrently) → fold into one `JudgeCriterionResult` carrying mean + -stddev + raw scores → expose stddev as a suite-thresholdable metric. - -**Open questions:** -- Default N and temperature — fixed (e.g. 3 @ T=1.0) or per-criterion? -- Does verdict spread gate the suite, or is it report-only to start? -- Cost ceiling: cap samples on `agent_judge` (expensive) differently from `llm_judge`? - ---- - -### cost-preflight -**Pre-flight budget validation + judge cost estimate** - -**What:** Validate run-time budget config *before* a run starts, and estimate judge -token cost before each judge fires. Two concrete fixes: (1) error (or loudly warn) -when `max_usd` is set on a route that can't report cost, instead of silently -skipping the budget; (2) estimate the judge context's token footprint so a giant -file list / dialog can't blow out context unexpectedly. - -**Why:** `max_usd` silently no-ops on routes without per-turn cost data — you think -you're capped and you're not, and you only find out at result-finalization. Judges -also burn tokens with zero pre-flight check. Both are cheap guardrails against -surprise cost. - -**What exists today:** -- Cost budget skipped with a one-time warning when no per-turn cost is available — - [src/coder_eval/orchestrator.py:640](../src/coder_eval/orchestrator.py#L640) -- `cost_data_available` recorded *post-hoc* in environment info — - [src/coder_eval/orchestrator.py:516](../src/coder_eval/orchestrator.py#L516) -- Judge context assembled (files + reference + dialog) with no token estimate — - [src/coder_eval/evaluation/judge_context.py](../src/coder_eval/evaluation/judge_context.py) - -**Rough approach:** add a pre-run validator that cross-checks `run_limits.max_usd` -against the resolved route's cost capability (error or explicit warn) → add a rough -token estimate to `JudgeContextBuilder` and log/cap it before dispatch. - -**Open questions:** -- Hard error vs. warn for `max_usd` on a cost-blind route? -- Estimate-only, or auto-trim context to fit a configured ceiling? - ---- - -### failure-mode-rollup -**Failure-mode taxonomy headline** - -**What:** Roll the *reasons* tasks failed into a run-level headline — counts of -`max_turns_exhausted`, `TOKEN_BUDGET_EXCEEDED`, `COST_BUDGET_EXCEEDED`, crashes/ -timeouts, and the top review tags (e.g. `prompt-gap`) — surfaced in `run.json`, the -Slack summary, and the run page. Turn "62 failures" into "31 prompt-gap, 18 -max-turns, 9 crashes, 4 budget." - -**Why:** The pass/fail headline says *how many* failed, never *why*. The categories -already exist across the codebase (final-status enums, per-task flags, review tags) -but nothing aggregates them into one actionable breakdown — the input that makes -[`top-offenders`](#top-offenders) and triage useful. - -**What exists today:** -- `FinalStatus.TOKEN_BUDGET_EXCEEDED` / `COST_BUDGET_EXCEEDED` and `max_turns_exhausted` - per task — [src/coder_eval/reports_experiment.py:42](../src/coder_eval/reports_experiment.py#L42) -- Error categorization for crashes/timeouts — - [src/coder_eval/errors/categorization.py](../src/coder_eval/errors/categorization.py) -- Per-task review tags indexed in `review_index.json` — - the nightly Slack summary - -**Rough approach:** aggregate per-task statuses + flags + tags into a `failure_modes` -block on the run summary → add one section to the Slack summary → render as a -breakdown bar on the run page. - -**Open questions:** -- Status enum + flags only, or blend in the (looser) review-tag taxonomy? -- Fixed buckets, or data-driven top-N categories per run? - ---- - -### judge-calibration -**Golden-set judge calibration + drift detection** - -**What:** A small, version-controlled set of artifacts with hand-assigned "correct" -scores per rubric, plus a command that runs the judges against it and reports -agreement (e.g. MAE / rank correlation vs. the golden scores). Run it in CI so a -prompt or model change that silently shifts judge behavior trips a gate. - -**Why:** Judges have no ground truth to tune against — we can't tell a good rubric -from a lenient one, and a model upgrade can re-baseline every judged score without -anyone noticing. A golden set turns "trust me" into a measurable, regression-tested -property and complements [`judge-ensemble`](#judge-ensemble) (variance) with -*accuracy*. - -**What exists today:** -- LLM + agent judges produce structured `JudgeVerdict`s — - [src/coder_eval/evaluation/judge_verdict.py](../src/coder_eval/evaluation/judge_verdict.py) -- Suite-level aggregation + thresholds already gate on metrics — - [src/coder_eval/reports.py](../src/coder_eval/reports.py) -- No fixture of graded reference verdicts exists anywhere in the repo. - -**Rough approach:** curate ~10–20 graded artifacts per judge rubric under `tests/` -→ add a `coder-eval judge-calibrate` command that scores them and reports -agreement → wire a CI gate on a minimum agreement threshold. - -**Open questions:** -- Per-rubric golden sets, or one shared spread of easy/medium/hard cases? -- Agreement metric — MAE, Spearman rank, or pass/fail confusion at the threshold? - ---- - -### result-cache -**Skip-unchanged task result cache** - -**What:** Cache completed task results keyed by a fingerprint of everything that -determines the outcome (resolved task config + agent/model + skills/cli SHA + -template/sandbox inputs). On the next run, tasks whose fingerprint is unchanged -*and* deterministic can be reused instead of re-executed — dramatically cutting -nightly wall-clock and cost. - -**Why:** Every nightly re-runs every task from scratch even when nothing relevant -changed, burning the 4h budget and real money. A content-addressed cache lets the -nightly spend its budget on what actually changed. (Pairs naturally with -[`config-fingerprint`](#config-fingerprint), which produces the same key.) - -**What exists today:** -- Batch runner already supports *resume* — partition prior results, re-run only - failed/skipped — [src/coder_eval/orchestration/batch.py](../src/coder_eval/orchestration/batch.py) -- No inter-run result cache; runs are fully independent. -- Agent runs are stochastic, so caching must be opt-in / fingerprint-gated. - -**Rough approach:** reuse the [`config-fingerprint`](#config-fingerprint) key → -store last-good results in a content-addressed store → on run start, short-circuit -tasks with a cache hit (respecting a `--no-cache` / replicate-count override) → -clearly mark reused vs. fresh in `run.json`. - -**Open questions:** -- Does caching even make sense given agent nondeterminism — only for deterministic - criteria, or never reuse the *agent* run and only cache *grading*? -- Where does the cache live (local dir vs. blob) and how is it invalidated/expired? - ---- - -### smart-judge-truncation -**Smarter judge context truncation** - -**What:** Replace the naïve "drop trailing turns when over budget" dialog truncation -with a strategy that preserves high-signal context — keep first + last K turns, or -middle-ellipsis — so the judge never loses the final user question on a long -simulation. There's already a TODO in the code calling for exactly this. - -**Why:** On long dialogs, FIFO-dropping the *most recent* turns can discard the -decisive exchange the rubric needs, quietly degrading verdict quality. The fix is -local and well-scoped, and it directly improves judge reliability. - -**What exists today:** -- `JudgeContextBuilder` truncation with a standing TODO ("keep first+last K, or - middle-ellipsis ... the naïve cap is enough for the common case") — - [src/coder_eval/evaluation/judge_context.py](../src/coder_eval/evaluation/judge_context.py) -- Per-file cap + aggregate dialog cap (`max_dialog_chars`, default 80K), with - degradation already marked in the context notes. - -**Rough approach:** implement a first+last-K (or middle-ellipsis) selector behind -the existing truncation point → record which turns were elided in the degradation -note → keep the naïve cap as a fallback option. - -**Open questions:** -- First+last-K vs. middle-ellipsis vs. relevance-scored selection — start simple? -- Same strategy for per-file truncation, or dialog-only for now? - ---- - -### per-criterion-telemetry -**Per-criterion timing + token telemetry** - -**What:** Record wall-clock time and (for judges) token/cost spend per criterion, so -we can answer "which criterion is slow/expensive?" Today criteria — especially the -two judge types — are a black box inside the per-task total. - -**Why:** When a task is slow or pricey, there's no way to attribute it to a specific -criterion. Judges in particular can dominate cost, and we can't see it. This is the -instrumentation that makes [`long-failing`](#long-failing) and cost triage precise -rather than guesswork. - -**What exists today:** -- `CriterionResult` carries score/details but no timing or token attribution — - [src/coder_eval/models/results.py](../src/coder_eval/models/results.py) -- Judge token usage is captured at the *task* level (via the run's backend / SDK), - not per-criterion — [src/coder_eval/orchestrator.py](../src/coder_eval/orchestrator.py) -- `SuccessChecker` dispatches criteria sequentially — - [src/coder_eval/evaluation/checker.py](../src/coder_eval/evaluation/checker.py) - -**Rough approach:** wrap each criterion check in a timer in `SuccessChecker` → -add `duration_ms` (+ optional `tokens`/`cost_usd` for judges) to `CriterionResult` -→ surface a per-criterion mini-breakdown on the task detail page. - -**Open questions:** -- Token attribution for judges — exact (per-call delta) or best-effort? -- Worth running independent criteria concurrently while we're in here, or out of scope? - ---- - -### diff-criterion -**Diff/patch validation criterion** - -**What:** A first-class criterion that validates the *change* an agent made — expected -files touched/untouched, hunks present, lines added/removed within bounds — by -diffing the sandbox against its starting state. Built for incremental-edit tasks -where "did it modify the right things, and only those?" matters more than final -file content. - -**Why:** Coding agents are increasingly graded on edits, not greenfield files. Today -that's only checkable via brittle `file_contains` substrings or custom `run_command` -shell. A diff-aware criterion captures edit intent directly and discourages -over-broad changes (a real failure mode). - -**What exists today:** -- 17 criterion types, none diff-aware; closest is `file_contains` / `file_matches_regex` — - [src/coder_eval/models/criteria.py](../src/coder_eval/models/criteria.py) -- Sandbox starts from templates/starter files, so a baseline-vs-final diff is - available — [src/coder_eval/sandbox.py](../src/coder_eval/sandbox.py) -- Plugin registry makes adding a checker mechanical (`@register_criterion`) — - [src/coder_eval/criteria/__init__.py](../src/coder_eval/criteria/__init__.py) - -**Rough approach:** add a `diff_check` model to the criteria union → checker computes -the sandbox-vs-baseline diff and scores against expected changed-files / hunk -patterns / add-remove bounds → fractional score so partial edits get partial credit. - -**Open questions:** -- Compare against git baseline, a snapshot of starter files, or a reference patch? -- Score on file-set match, hunk match, or both (weighted)? - ---- - -## How to add an idea - -1. Pick a short, stable **ID** (kebab-case) and add a `### ` section using the - template above (What / Why / rough approach / open questions). -2. Score it on Impact and Effort (1–5), compute **Score = Impact + (6 − Effort)**. -3. Insert a row in the **Backlog** table at the position its Score implies — that - position *is* its rank. Done. diff --git a/docs/REPORT_SCHEMA.md b/docs/REPORT_SCHEMA.md new file mode 100644 index 00000000..3aceab87 --- /dev/null +++ b/docs/REPORT_SCHEMA.md @@ -0,0 +1,256 @@ +--- +description: >- + Field-level reference for Coder Eval's JSON outputs — run.json, task.json, + variant.json, experiment.json, and suite.json — plus the token/criterion + telemetry sub-models and FinalStatus values, for anyone consuming a run. +--- + +# Report Schema + +Coder Eval writes machine-readable JSON alongside every markdown/HTML report. This +page is the field-level reference for consumers (dashboards, CI parsers, evalboard +forks). For the on-disk directory tree see +[User Guide → Output Structure](USER_GUIDE.md#output-structure); for how to +re-generate these files see [`coder-eval report` / `aggregate`](USER_GUIDE.md#cli-commands). + +All JSON is Pydantic `model_dump_json` output — keys are the model field names +verbatim (no aliases, except `iterations` also accepts the legacy key `turns` on +read). Times are ISO-8601. + +## Who writes what + +| File | Model | When | +| --- | --- | --- | +| `run.json` / `run.md` | `RunSummary` | Every run (and rebuildable via `coder-eval aggregate`) | +| `///task.json` | `EvaluationResult` | One per replicate | +| `//suite.json` / `.md` | `SuiteRollup` | Dataset-backed suites only | +| `experiment.json` / `.md` | `ExperimentResult` | Every run (experiment layer) | +| `/variant.json` / `.md` | `VariantAggregate` | Per variant | + +`` is the zero-padded replicate index. Judge transcripts spill to sibling files +(e.g. `judge-0.yaml`) referenced by a `transcript_path`. + +--- + +## `run.json` — `RunSummary` + +**`run.json` is flat, not a `run → variant → task → replicate` tree.** It is the +run-level summary; full per-replicate detail lives in each `task.json`. + +| Key | Type | Meaning | +| --- | --- | --- | +| `run_id` | `str` | Timestamp id, e.g. `"2025-10-09_15-30-45"`. | +| `start_time` / `end_time` | `datetime` | Run window. | +| `total_duration_seconds` | `float` | Wall-clock. | +| `tasks_run` | `int` | Total replicates executed. | +| `tasks_succeeded` / `tasks_failed` / `tasks_error` | `int` | Category counts. **Invariant:** the three sum to `tasks_run`. | +| `tasks_token_budget_exceeded` / `tasks_cost_budget_exceeded` | `int` | Sub-counters of `tasks_failed` (not part of the invariant). | +| `skipped_tasks` | `list[{path, reason}]` | Load failures / `skip: true` opt-outs. | +| `max_parallel` | `int` | Concurrency used. | +| `task_results` | `list[dict]` | Flat per-replicate rows — see below. | +| `framework_version` | `str` | Coder Eval version chip. | +| `environment_info` | `dict` | Version/dependency info (may nest, e.g. `tool_plugins`). | + +### `task_results[]` — the flat per-task row + +Each entry is an **untyped dict** (a denormalization, not a Pydantic model) with keys +including: `task_id`, `replicate_index`, `variant_id`, `status` +([`FinalStatus`](#finalstatus)), `weighted_score`, `duration`, `iteration_count`, +`tags`, `task_path`, `model_used`, `reference_similarity`, the token buckets +(`input_tokens` = uncached input, `output_tokens`, `cache_creation_input_tokens`, +`cache_read_input_tokens`, `total_tokens`), `total_cost_usd`, `expected_commands`, +`actual_commands`, `commands_efficiency`, `agent_config`, `sdk_options`, +`installed_tools`, turn accounting (`total_turns`, `visible_turns`, `expected_turns`, +`max_turns_exhausted`, `has_final_reply`), and early-stop fields (`stopped_early`, +`early_stop_reason`, `turns_remaining_at_stop`). `iterations` here is a **reduced** +turn digest (`{iteration, duration_seconds, command_count, assistant_turn_count, +crashed, crash_reason}`) — the full transcript is in `task.json`. + +--- + +## `task.json` — `EvaluationResult` + +The authoritative per-replicate record. + +**Identity/metadata:** `task_id`, `task_description`, `variant_id` (default +`"default"`), `agent_type`, `model_used`, `started_at`, `completed_at`, +`duration_seconds`. + +**Results:** + +| Key | Type | Meaning | +| --- | --- | --- | +| `final_status` | [`FinalStatus`](#finalstatus) | Terminal status. | +| `weighted_score` | `float \| null` | Weighted average of criterion scores, 0.0–1.0. | +| `max_turns_exhausted` | `bool` | Ran out of turns. | +| `iteration_count` | `int` | Number of turns. | +| `success_criteria_results` | `list[CriterionResult]` | Per-criterion results — see [below](#criterionresult). | + +**Transcript:** `iterations: list[TurnRecord]` (accepts legacy alias `turns`) — see +[TurnRecord](#turnrecord). + +**Errors** (populated on failure): `error_message`, `error_details`, +`error_log_tail` (carries the Docker build-log tail for `BUILD_FAILED`). + +**Config/environment:** `environment_info`, `agent_config`, `sdk_options` (raw +`ClaudeAgentOptions` dump), `sandbox_path`, `task_config` +(`{resolved, source_yaml, source_file, lineage}` — `lineage` maps each field to +`{value, source, source_detail}` so you can trace which config layer set it). + +**Telemetry/totals:** `total_token_usage` ([TokenUsage](#tokenusage)), +`command_stats` (`CommandStatistics`), `total_assistant_turns`, `expected_commands` / +`actual_commands` / `commands_efficiency`, `pre_run_results` / `post_run_results` +(`{command, exit_code, stdout, stderr, duration_seconds, error}`), `simulation` +(dialog-mode telemetry, `null` in single-shot), and `early_stop` +([EarlyStopInfo](#earlystopinfo)). + +### CriterionResult + +A discriminated union on `result_kind` (`basic` / `judge` / `classification`); legacy +files without `result_kind` are inferred from `criterion_type`. Base fields +(`result_kind="basic"`): + +`criterion_type`, `description`, `score` (0.0–1.0), `details`, `error`, +`pass_threshold` (default 0.9), `gating` (default `true`; `false` = informational / +weight-0, excluded from the score and the pass/fail gate). The base allows extra +fields so subclass keys round-trip. + +- **`classification`** adds `observed_label`, `expected_label` (sentinels like + `(none)` / `(other)` allowed). Emitted by `classification_match`, `skill_triggered`. +- **`judge`** adds `findings`, `token_usage` (kept distinct from the agent total), and + `transcript_path` (a sibling `judge-N.yaml`). The full `transcript` is **stripped + from `task.json`** — read it from the referenced file. Emitted by `llm_judge`, + `agent_judge`. + +### TurnRecord + +`iteration`, `user_input`, `agent_output`, `commands` (`list[CommandTelemetry]`), +`timestamp`, `duration_seconds`, `token_usage`, `model_used`, `assistant_turn_count`, +`messages` (`list[TranscriptMessage]`, discriminated on `role`: +`user`/`assistant`/`reconciliation`), `num_turns`, `max_turns_exhausted`, +`result_summary` (`{is_error, subtype, stop_reason, result}`), `crashed`, +`crash_reason`. + +> **Token invariant.** Summing the four token buckets across `messages` +> (assistant + the synthetic `reconciliation` entry) equals `token_usage` exactly. +> The `reconciliation` message carries the residual the per-message stream +> under-reports; it has no cost and is excluded from turn/generation counts. See the +> [Claude Code guide](agents/CLAUDE_CODE.md#telemetry). + +### EarlyStopInfo + +Present (non-`null`) iff the run stopped early — there is no separate boolean. +Fields: `reason` (`criterion_passed` / `criterion_failed`), +`deciding_criterion_type`, `deciding_criterion_description`, `armed_criteria`, +`sdk_turn_index`, `tool_call_index` (1-based, includes the in-flight call), +`elapsed_seconds`, `turns_remaining_at_stop`. + +--- + +## `variant.json` — `VariantAggregate` + +A single aggregate (not wrapped): `variant_id`, `tasks_run`, `tasks_succeeded`, +`tasks_failed`, `tasks_error` (same sum-to-`tasks_run` invariant), `average_score`, +`average_duration`, `total_tokens`, `replicate_count`, `tasks_token_budget_exceeded`, +`tasks_cost_budget_exceeded`. + +## `experiment.json` — `ExperimentResult` + +The cross-variant summary: + +- `experiment_id`, `description`, `variant_ids`. +- `task_summaries: list[TaskExperimentSummary]` — each + `{task_id, variant_results, best_variant, is_tie, score_spread, replicate_count}`, + where each `VariantResult` carries `{variant_id, task_id, weighted_score, + final_status, duration_seconds, total_tokens, iteration_count, + total_assistant_turns, reference_similarity, replicate_index, replicate_count}`. +- `variant_aggregates: dict[str, VariantAggregate]` — keyed by variant id. +- `total_duration_seconds`. +- `per_replicate_scores: dict[variant_id -> dict[task_id -> list[float]]]`. + +> **Statistics are render-time only.** Bootstrap/Wilson confidence intervals and the +> Welch/paired mean-difference tests appear in `experiment.md` / HTML but are **not** +> serialized into `experiment.json`. A consumer that wants CIs must recompute them +> from `per_replicate_scores`. + +## `suite.json` — `SuiteRollup` + +Written for dataset-backed suites; its `passed` flag drives the CI exit code. + +| Key | Type | Meaning | +| --- | --- | --- | +| `suite_id` / `variant_id` | `str` | Identity. | +| `rows_total` / `rows_passed` / `rows_failed` / `rows_error` | `int` | Row counts. | +| `pass_rate` | `float` | `rows_passed / rows_total`. | +| `average_weighted_score` | `float \| null` | Mean row score. | +| `criterion_stats` | `list[{criterion_type, rows_evaluated, average_score, error_count}]` | Per-criterion summary. | +| `failed_samples` | `list[FailedRowSummary]` | Capped at 20 (`{row_id, task_id, final_status, weighted_score, failure_reasons, error_message, task_json_relpath, replicate_index}`). | +| `criterion_aggregates` | `list[CriterionAggregate]` | The thresholdable metrics — see below. | +| `passed` | `bool` | All aggregates met their thresholds. | + +### CriterionAggregate & ThresholdCheck + +`CriterionAggregate`: `criterion_type`, `description`, `rows_total`, `rows_excluded`, +`metrics` (a **flat** `dict[str, float]`, e.g. `accuracy`, `macro_f1`, +`precision.yes`, `recall.yes`, `f1.yes`), `threshold_checks`, `passed`, `details` +(untyped render extras — for classification: `labels`, `per_label`, `confusion`), +`error`. + +`ThresholdCheck`: `metric`, `min_value`, `actual_value` (`null` if the aggregator +didn't emit that metric), `passed` (`actual_value >= min_value`). These correspond to +the `suite_thresholds` you set on a criterion — see +[User Guide → Suite Thresholds](USER_GUIDE.md#suite-thresholds--classification-metrics). + +--- + +## TokenUsage + +Serialized fields: `uncached_input_tokens`, `output_tokens`, +`cache_creation_input_tokens`, `cache_read_input_tokens`, `total_cost_usd`, plus a +computed **`input_tokens`** (= sum of the three input buckets). Note: `total_tokens` +is a plain property and is **not** serialized. Cost bills `uncached_input_tokens`, +not `input_tokens`. Legacy files that only have `input_tokens` are adopted as +`uncached_input_tokens` on read. + +## FinalStatus + +String enum values and their reporting category: + +| Value | Category | Icon | +| --- | --- | --- | +| `SUCCESS` | succeeded | `+` | +| `FAILURE` | failed | `-` | +| `TIMEOUT` | failed | `T` | +| `MAX_TURNS_EXHAUSTED` | failed | `M` | +| `TOKEN_BUDGET_EXCEEDED` | failed | `#` | +| `COST_BUDGET_EXCEEDED` | failed | `$` | +| `ERROR` | error | `!` | +| `BUILD_FAILED` | error | `B` | + +> **Gotcha:** `BUILD_FAILED` (a failed Docker image build) categorizes as **error**, +> not failed — easy to miscount downstream. + +`TOKEN_BUDGET_EXCEEDED` and `COST_BUDGET_EXCEEDED` are produced by the cumulative budget caps under +`run_limits:` (`max_input_tokens` / `max_output_tokens` / `max_total_tokens`, and `max_usd` +respectively), checked after each completed agent turn — see +[Task Definition Guide → Run Limits](TASK_DEFINITION_GUIDE.md#run-limits). + +--- + +## Consumer gotchas at a glance + +- `run.json.task_results` is a flat, untyped denormalization — the typed source of + truth is each `task.json`. +- Experiment CIs / significance tests are **not** in `experiment.json` (render-time + only); recompute from `per_replicate_scores`. +- Judge `transcript` is stripped from `task.json`; follow `transcript_path`. +- `TokenUsage.total_tokens` is not serialized; sum the buckets (or use the computed + `input_tokens` + `output_tokens` + cache buckets). +- `EarlyStopInfo` presence is itself the "stopped early" signal. + +## See also + +- [User Guide → Output Structure](USER_GUIDE.md#output-structure) and the + [`aggregate`](USER_GUIDE.md#cli-commands) command +- [A/B Experiments → Reading the Report](AB_EXPERIMENTS.md#reading-the-report) +- [Task Definition Guide](TASK_DEFINITION_GUIDE.md) diff --git a/docs/TASK_DEFINITION_GUIDE.md b/docs/TASK_DEFINITION_GUIDE.md index d0baa887..0e75f25b 100644 --- a/docs/TASK_DEFINITION_GUIDE.md +++ b/docs/TASK_DEFINITION_GUIDE.md @@ -1,18 +1,21 @@ --- description: >- - Full schema reference for coder_eval task YAML — agent config, sandboxes, run + Full schema reference for Coder Eval task YAML — agent config, sandboxes, run limits, dataset fan-out, and all 14 success criterion types with weighted 0.0–1.0 scoring. --- # Task Definition Guide -Complete reference for defining evaluation tasks in coder_eval. +Complete reference for defining evaluation tasks in Coder Eval. ## Table of Contents - [Task YAML Structure](#task-yaml-structure) + - [dataset](#dataset) + - [skip](#skip) - [Agent Configuration](#agent-configuration) +- [Run Limits](#run-limits) - [Sandbox Configuration](#sandbox-configuration) - [Template Sources](#template-sources) - [Success Criteria](#success-criteria) @@ -46,18 +49,72 @@ description: "What this task tests" # Human-readable description (required) initial_prompt: "Instructions..." # Prompt sent to the agent (required) tags: [smoke, golden, pure-python] # Optional tags for filtering (kebab-case) +skip: false # Optional: quarantine this task (see below) + agent: { ... } # Agent configuration (optional, resolved from experiment) sandbox: { ... } # Sandbox configuration (optional, defaults to tempdir) +run_limits: { ... } # Optional run-time caps (turns, wall-clock, tokens, USD) success_criteria: [ ... ] # List of criteria (required, at least 1) reference: { ... } # Optional reference solution pre_run: [ ... ] # Optional pre-run commands (before agent starts) post_run: [ ... ] # Optional post-run commands +dataset: { ... } # Optional dataset fan-out (one task -> N row-tasks) ``` +### `dataset` + +An optional `dataset:` block fans this single task out into **one sub-task per row**. Each row-task +gets its own sandbox, run directory, and `task.json`; its `task_id` becomes `/`. +Row values substitute into `initial_prompt` and into the string leaves of `success_criteria` via +`${row.}`. Expansion happens at load time, **before** experiment-variant resolution, so a +variant can never override the dataset. + +```yaml +dataset: + rows: # inline rows — mutually exclusive with `paths` + - id: alpha + expected: "alpha" + # paths: ["datasets/rows.jsonl"] # or JSONL files, relative to this task YAML + id_field: "id" # which row field is the row identifier + sample_per_stratum: 5 # optional: keep up to N rows per stratum + stratify_field: "expected_skill" # which row field defines the stratum + sample_seed: 1234 # optional: pin the stratified draw +``` + +| Field | Default | Description | +|-------|---------|-------------| +| `rows` | `null` | Inline list of row dicts. Mutually exclusive with `paths`; exactly one is required. | +| `paths` | `null` | JSONL file paths **relative to the task YAML**, concatenated in declared order. | +| `id_field` | `"id"` | Row field used as the row identifier. Must be present, unique, and match `^[A-Za-z0-9_][A-Za-z0-9_.\-]*$` (it becomes a directory name). | +| `sample_per_stratum` | `null` | Stratified random sample: keep up to N rows per stratum. Overridden by CLI `--sample`. | +| `stratify_field` | `"expected_skill"` | Row field whose value defines the stratum for `sample_per_stratum`. | +| `sample_seed` | `null` | Seed for the stratified draw. Unset means the sample is **re-drawn every run**; set an integer to pin it. CLI `--sample` is separately fixed-seed and always reproducible. | + +Full guide — row sources, substitution rules, sampling precedence, suite-level scoring, and worked +examples: **[Bring Your Own Dataset](DATASETS.md)**. + +### `skip` + +`skip: true` quarantines a task. The runner records it in `RunSummary.skipped_tasks` at resolution +time and it **never reaches the orchestrator** — no dataset fan-out, no variant resolution, no +sandbox, no API call. Use it to park a task that is blocked on something outside your control +(an upstream bug, a missing service) without deleting the YAML and losing its history. + +```yaml +task_id: "codex_disallowed_tools_test" +# Blocked: the Codex SDK doesn't enforce disallowed_tools via config. Re-enable +# once upstream ships the fix. +skip: true +``` + +Pair it with a comment naming the blocker — a ticket link, an upstream issue — so the next reader +knows what has to change before it can come back. Run quarantined tasks on demand with +`coder-eval run --include-skipped`; CI leaves the flag off, so they stay excluded there. + ## Tags -Tags categorize tasks for selective execution. Each tag is lowercase kebab-case and may +The `tags` list categorizes tasks for selective execution. Each tag is lowercase kebab-case and may optionally be namespaced as `key:value` where both sides are kebab-case. ```yaml @@ -95,14 +152,10 @@ coder-eval run tasks/*.yaml --exclude-tags example # Skip example tasks ## Agent Configuration -```yaml -# Run-time caps (turns, wall-clock, tokens, USD) live under run_limits -run_limits: - max_turns: 20 # Optional: hard cap on inner-loop turns per iteration - expected_turns: 8 # Optional: SOFT efficiency budget (visible turns) — not a cap - turn_timeout: 300 # Optional: per-communicate() timeout in seconds - task_timeout: 600 # Optional: wall-clock cap across all iterations +Run-time caps (turns, wall-clock, tokens, USD) are **not** part of this block — they live under +[`run_limits`](#run-limits). +```yaml agent: type: "claude-code" # Agent type — optional if supplied via experiment / --type permission_mode: "acceptEdits" # Permission mode (see below) @@ -116,7 +169,7 @@ agent: ``` **`sdk_options`** is a typed pass-through dict for Claude Code SDK -`ClaudeAgentOptions` fields that coder_eval doesn't own directly. Keys are +`ClaudeAgentOptions` fields that Coder Eval doesn't own directly. Keys are validated against the SDK's dataclass at YAML load; framework-managed keys (`model`, `allowed_tools`, `permission_mode`, `hooks`, `mcp_servers`, …) are rejected. Deep-merged across the 5-layer config chain. Override via @@ -130,7 +183,7 @@ an error. - `plan` — Agent proposes changes, waits for approval - `bypassPermissions` — No permission checks (use with caution) -> **Codex note:** `permission_mode` confines the **`claude-code`** agent only. The **`codex`** agent always runs full-access regardless of the mode — its in-process OS sandbox is redundant given coder_eval's docker/tempdir isolation and unusable on our CI hosts (and on Windows). Run adversarial or untrusted Codex evals under the **docker driver**, which is the OS-level write boundary; the tempdir/host driver is a working directory, not a confinement boundary. +> **Codex note:** `permission_mode` confines the **`claude-code`** agent only. The **`codex`** agent always runs full-access regardless of the mode — its in-process OS sandbox is redundant given Coder Eval's docker/tempdir isolation and unusable on our CI hosts (and on Windows). Run adversarial or untrusted Codex evals under the **docker driver**, which is the OS-level write boundary; the tempdir/host driver is a working directory, not a confinement boundary. **Agent Types:** - `claude-code` (default) — Claude Code SDK agent. Supports `sdk_options`, `claude_settings`, and all permission modes. @@ -172,11 +225,78 @@ trajectory (`command_executed`, `skill_triggered`, `reference_comparison`, `commands_efficiency`) are rejected. A worked example lives at [`tasks/agentless_smoke_test.yaml`](https://github.com/UiPath/coder_eval/blob/main/tasks/agentless_smoke_test.yaml). -### `max_turns`, `task_timeout`, `turn_timeout` location +## Run Limits -These live under `run_limits:` on the task — alongside the token / USD -budget caps. They are scenario constraints; the agent identity (type, -model, etc.) is conceptually separate. +`run_limits:` is the single namespace for every run-time cap: the **structural** caps that bound how +long a task may run, and the **budget** caps that bound what it may spend. Any subset of fields is +valid and an empty block is legal — every field defaults to "no limit". + +```yaml +run_limits: + # Structural caps + max_turns: 20 # hard cap on agent inner-loop turns per iteration + expected_turns: 8 # SOFT efficiency budget (visible turns) — never aborts + task_timeout: 600 # wall-clock cap across all iterations, seconds + turn_timeout: 300 # per-communicate() timeout, seconds + + # Budget caps + max_total_tokens: 200000 # cumulative input + output + max_usd: 2.50 # cumulative cost + + # Early stop + stop_early: true # end once the armed criteria are decided +``` + +| Field | Default | Constraint | Description | +|-------|---------|------------|-------------| +| `max_turns` | *unset* | `> 0` | Hard cap on agent inner-loop turns per iteration. Unset uses the SDK default. | +| `expected_turns` | *unset* | `>= 1` | **Soft** target for cumulative visible turns. Exceeding it warns and badges the report; it never aborts. See [`expected_turns`](#expected_turns-soft-efficiency-budget). | +| `task_timeout` | *unset* | `>= 30` | Max seconds for the whole evaluation loop (all iterations). | +| `turn_timeout` | *unset* | `>= 10` | Max seconds for a single agent `communicate()` call. | +| `max_input_tokens` | *unset* | `>= 1` | Max cumulative input (prompt) tokens. | +| `max_output_tokens` | *unset* | `>= 1` | Max cumulative output (completion) tokens. | +| `max_total_tokens` | *unset* | `>= 1` | Max cumulative input + output tokens. Distinct from [`simulation.max_total_tokens`](#simulation-multi-turn-user-dialog) — see the note below. | +| `max_usd` | *unset* | `> 0.0` | Max cumulative cost in USD. Requires per-turn SDK cost reporting. | +| `count_cached_input` | `false` | — | Count `cache_read_input_tokens` toward the input/total budgets. Off by default — cached reads are typically free. | +| `count_cache_creation` | `false` | — | Count `cache_creation_input_tokens` toward the input/total budgets. Off by default. | +| `stop_early` | `false` | — | Opt-in master switch for early-stop-on-criterion. See [`stop_early`](#stop_early-opt-in-early-stop). | + +The authoritative source is `src/coder_eval/models/limits.py`. A lint rule (CE030) fails the build if +a field defined there goes undocumented in this guide, so the table can't quietly fall behind the +model. + +**Budget-cap semantics:** + +- **Checked after each completed agent turn**, and **cumulative** across all of the task's turns. + There is no mid-turn enforcement, so a single runaway turn can overshoot the cap before the + between-turns check sees it. Size caps with headroom for one turn. +- **Subject agent only.** Judge (`llm_judge` / `agent_judge`) and user-simulator token spend are + **not** counted against these caps. +- A breach aborts the task with `FinalStatus.TOKEN_BUDGET_EXCEEDED` (any of the three token caps) or + `FinalStatus.COST_BUDGET_EXCEEDED` (`max_usd`). Both categorize as `failed` — see + [Report Schema](REPORT_SCHEMA.md). +- **`max_usd` needs per-turn cost from the SDK.** If no turn reports a cost, the check is **skipped + with a one-shot warning per task**, not failed. A run can therefore blow past `max_usd` silently + on a backend that doesn't report cost — don't rely on it as your only guardrail. +- **Cached-read and cache-creation tokens are excluded by default.** `count_cache_creation: true` is + what makes an input-token budget meaningful for **Codex**, which buckets its fresh (full-price) + prompt slice into `cache_creation`; with the default `false`, a Codex token budget effectively + caps output only. + +**Setting caps from the CLI** — every field is reachable through `-D`, which merges per-key into +`run_limits` without disturbing the task's other caps: + +```bash +coder-eval run task.yaml -D run_limits.max_turns=30 -D run_limits.task_timeout=900 +coder-eval run task.yaml -D run_limits.max_usd=2.50 -D run_limits.max_total_tokens=200000 +``` + +> **`run_limits.max_total_tokens` vs. `simulation.max_total_tokens`.** They are different budgets. +> `run_limits.max_total_tokens` caps the **subject agent's** cumulative tokens and **aborts the task** +> with `TOKEN_BUDGET_EXCEEDED`. [`simulation.max_total_tokens`](#simulation-multi-turn-user-dialog) +> caps the **whole dialog** — simulator *plus* agent — and ends the dialog gracefully with +> `stop_reason='budget'`, leaving the task to be scored normally. Set both if you want a hard +> ceiling on a simulated task. > **No longer supported:** `max_turns` / `turn_timeout` (and top-level > `task_timeout`) under `agent:` or at the task top level are rejected — @@ -394,9 +514,9 @@ All criteria share these fields: | `stop_when` | `null` | Arms this criterion for early stop (`pass`/`fail`/`decided`); requires `run_limits.stop_early: true` and an observable criterion type (`skill_triggered`, `command_executed`). See [`stop_early`](#stop_early-opt-in-early-stop). | **Scoring types:** -- **Binary** (1.0 or 0.0): `file_exists`, `run_command`, `file_matches_regex` +- **Binary** (1.0 or 0.0): `file_exists`, `run_command`, `file_matches_regex`, `classification_match`, `skill_triggered` - **Fractional** (0.0–1.0): `file_contains`, `file_check`, `json_check`, `command_executed`, `uipath_eval` -- **Continuous** (0.0–1.0): `reference_comparison`, `llm_judge`, `agent_judge` +- **Continuous** (0.0–1.0): `reference_comparison`, `commands_efficiency`, `llm_judge`, `agent_judge` **Task success:** all *gating* criteria must score >= their `pass_threshold`. A criterion with `weight: 0` is informational — it is still checked, stored, and @@ -616,6 +736,22 @@ Checks whether the agent executed specific tools/commands during evaluation. Ins **Codex limitation.** Codex agents map `Read`, `Grep`, and `Glob` tools to `shell` commands (they execute via bash), so `tool_name: "Read"` on Codex returns no matches. Use `tool_name: "Bash"` or `tool_name: null` (any tool) for Codex-compatible checks. This criterion works correctly on Claude Code agents, which emit separate `Read`/`Grep`/`Glob` telemetry. +### `commands_efficiency` + +Scores how economically the agent worked, relative to a budget of expected tool calls. **Continuous scoring:** `score = expected_commands / max(actual_commands, expected_commands)` — so a run at or under budget scores `1.0`, and the score decays as the agent takes more calls than expected (e.g. twice the budget → `0.5`). + +```yaml +- type: "commands_efficiency" + expected_commands: 8 # budget of tool calls to complete the task (>= 1) + description: "Agent should solve this in ~8 tool calls" +``` + +| Field | Default | Description | +|-------|---------|-------------| +| `expected_commands` | *required* | Expected number of tool commands to complete the task (integer, `>= 1`). | + +This criterion requires an agent run (it reads `CommandTelemetry`). Pair it with a low `weight` if you want efficiency to *inform* the score without gating pass/fail on its own. + ### `uipath_eval` Evaluates a UiPath agent against a named evaluation set. **Fractional scoring:** metrics passed / total metrics. @@ -640,7 +776,7 @@ Evaluates a UiPath agent against a named evaluation set. **Fractional scoring:** ### `llm_judge` -Have an LLM grade the task against a rubric written in the task YAML. **Continuous scoring** from a JSON verdict `{"score": 0.0-1.0, "rationale": "..."}`; parse failure, non-numeric score, or LLM error all produce `score=0.0` with an `error` populated. +Have an LLM grade the task against a rubric written in the task YAML. **Continuous scoring** from a verdict the judge returns via a forced `submit_verdict` tool call (`{score: 0.0-1.0, rationale: "..."}`) — the model never returns free-form prose. A missing/malformed verdict, a non-numeric score, or an LLM error all produce `score=0.0` with an `error` populated. ```yaml - type: "llm_judge" @@ -657,7 +793,7 @@ Have an LLM grade the task against a rubric written in the task YAML. **Continuo include_dialog: false # Opt-in: include the full user<->agent conversation (recommended for simulation) model: "anthropic.claude-sonnet-4-6" temperature: 0.0 - max_tokens: 1000 + max_tokens: 2000 max_file_chars: 20000 # Per-file content truncation weight: 2.0 pass_threshold: 0.7 @@ -674,7 +810,7 @@ Have an LLM grade the task against a rubric written in the task YAML. **Continuo | `max_dialog_chars` | `80000` | Aggregate cap on dialog text rendered into the judge prompt (per-message cap is `max_file_chars`). When exceeded, trailing turns are dropped and a degraded note is recorded. | | `model` | `anthropic.claude-sonnet-4-6` | Judge model id (vendor-prefixed; auto-translated per backend) | | `temperature` | `0.0` | Sampling temperature (0.0 = deterministic) | -| `max_tokens` | `1000` | Maximum tokens in the judge's response | +| `max_tokens` | `2000` | Maximum tokens in the judge's response | | `max_file_chars` | `20000` | Per-file (and agent_output) truncation applied before building the prompt | **Transport selection.** The judge call is routed by the active `API_BACKEND`: @@ -694,14 +830,14 @@ The `direct`-mode transport is resolved once at startup, logged on the `API rout **Failure modes** — each sets `score=0.0` and populates `error`: -- Non-JSON response from the model (parse failure) -- `score` key missing from the JSON verdict +- The judge never emits the forced `submit_verdict` tool call (no verdict returned) +- `score` key missing from the verdict - `score` is not coercible to float - Judge backend unavailable / network error (handled by `@handle_criterion_errors`) ### `agent_judge` -Spawn a full Claude Code SDK agent as the judge. Unlike `llm_judge` (a single LLM call against a rubric), the judge agent has **tool access** — Bash, Read, Write, Glob, Grep, Edit by default — and runs in an isolated copy of the task sandbox. Use it when functional validation requires executing something (`uip rpa get-errors`, `xmllint`, a test suite) rather than just inspecting file content. +Spawn a full Claude Code SDK agent as the judge. Unlike `llm_judge` (a single LLM call against a rubric), the judge agent has **tool access** — a read-only toolkit of `Bash`, `Read`, `Glob`, `Grep` by default (no `Write`/`Edit`) — and runs in an isolated copy of the task sandbox. Use it when functional validation requires executing something (`uip rpa get-errors`, `xmllint`, a test suite) rather than just inspecting file content. ```yaml - type: "agent_judge" @@ -776,6 +912,30 @@ The judge runs with the evaluator's API credentials and can execute arbitrary Ba - `TurnTimeoutError` (judge exceeded `turn_timeout`) - SDK subprocess failure (e.g. `claude` CLI missing) +### `classification_match` + +Matches a single label the agent wrote to a file against ground truth — the file-based classifier. Reads the file, normalizes the content (strip, and lowercase unless `case_sensitive`), and compares it to `expected_label`. **Binary scoring:** `1.0` on a match, else `0.0`. + +The observed label is the canonical form from `allowed_labels` when the content matches; otherwise `(none)` when the file is missing/empty and `(other)` when the content isn't in the allowed set. Both sentinels are recorded so a suite rollup shows them as real failure classes in the confusion matrix. + +```yaml +- type: "classification_match" + path: "result.txt" # file (relative to sandbox) holding the agent's predicted label + expected_label: "positive" + allowed_labels: [positive, negative] + case_sensitive: false # default: case-insensitive + canonicalized + description: "Sentiment label matches ground truth" +``` + +| Field | Default | Description | +|-------|---------|-------------| +| `path` | *required* | File (relative to sandbox) containing the agent's predicted label. | +| `expected_label` | *required* | Ground-truth label for this row (drive it from `${row.…}` on a dataset-backed task). | +| `allowed_labels` | *required* | Canonical label set (≥1). Content not in this set becomes `(other)`. | +| `case_sensitive` | `false` | When `false`, matching is case-insensitive and labels are canonicalized. | + +Like `skill_triggered`, this criterion emits a `ClassificationCriterionResult`, so on a [dataset-backed task](#dataset) the suite aggregator computes accuracy / precision / recall / F1 and a confusion matrix — gate them with `suite_thresholds`. Use `classification_match` when the agent writes its answer to a file (labeling/extraction tasks); use `skill_triggered` when the signal is whether a skill fired. + ### `skill_triggered` Binary classifier: **did the agent engage the target skill during the run?** Agent-agnostic — scans the run's `turn_records` for either signal: Claude's explicit `Skill` tool call whose `skill` parameter matches `skill_name` (namespace prefixes like `plugin:skill` are stripped, so `skill_name: uipath-agents` matches `Skill(skill="uipath-coded-agents:uipath-agents")`), or — for an agent with no `Skill` tool, e.g. Codex — a command that reads the skill's files off disk (a parameter contains `skills//`, matching both the repo path and the `.agents/skills/` symlink). @@ -799,7 +959,7 @@ Observed label is `"yes"` when either signal is found, else `"no"`. Expected lab **Requires agent telemetry.** This criterion reads `turn_records`, so it only works against a real agent run (not a static check). With no turn records it reports `score=0.0` and an `error`. -**Classification metrics.** `skill_triggered` returns a `ClassificationCriterionResult`, so on a [dataset-backed task](#task-yaml-structure) the suite aggregator computes accuracy / precision / recall / F1 / confusion matrix across all rows. Gate the suite with `suite_thresholds` using any of: `accuracy`, `macro_f1`, `weighted_f1`, `micro_f1`, or per-label `precision. + +## Suite Thresholds & Classification Metrics + +Any criterion on a **dataset-backed** task (see [Bring Your Own Dataset](DATASETS.md)) +can gate the whole suite, not just individual rows. Each criterion's `aggregate()` +emits `count / mean / median / std / min / max`, and classification-style criteria +(`classification_match`, `skill_triggered`) additionally emit accuracy, per-label +precision/recall/F1, and a confusion matrix. Add `suite_thresholds` to require a +minimum for any of those metrics — the run exits non-zero if any gate fails: + +```yaml +success_criteria: + - type: skill_triggered + skill_name: uipath-agents + expected_skill: "${row.expected_skill}" # "" for rows where it shouldn't fire + suite_thresholds: + recall.yes: 0.70 # fired on ≥70% of rows that needed it + precision.yes: 0.80 # ≤20% false activations +``` + +Available metric keys: `accuracy`, `macro_f1`, `weighted_f1`, `micro_f1`, and +per-label `precision.