Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,8 @@ action.yml # Published composite GitHub Action (coder-ev
- **Sub-agent token accounting**: There is NO separate per-sub-agent field. Every sub-agent generation is captured as a `parent_tool_use_id`-tagged `AssistantMessage` in the turn transcript, so per-sub-agent usage is derived by grouping those messages on that id (the evalboard's `aggregateSubAgentUsage` does exactly this). Claude bubbles its sub-agent's intermediate generations into the parent stream natively, and the **terminal** generation (delivered as the Agent tool result, never streamed) is synthesized into one via `_synthesize_subagent_terminal_message` from `tool_use_result.usage`. Codex reconstructs all child generations from the child rollout (`_recover_subagent_tool_calls`). The turn total already includes sub-agent cost — Claude via the SDK's cumulative `model_usage`; Codex via `_fold_subagent_tokens`, which folds the child messages (their real per-generation tokens) into the parent total. `CommandTelemetry.result_summary` is stored **untruncated** (no 200-char cap) so sub-agent returns are preserved whole. Set `CODER_EVAL_RAW_SDK_LOG=1` to dump every raw SDK event to the task log for inspection.
- **Reconciliation message (stream self-reconciles to the turn total)**: The per-message stream consistently under-reports the authoritative turn total — a fixed prompt slice (~512 input tokens on Claude) is billed on no SDK-emitted message, and sub-agent input/cache only partially bubbles up. So `EventCollector.build_turn_record` appends one synthetic `ReconciliationMessage` (`role="reconciliation"`, in the `TranscriptMessage` union) per turn, carrying the per-bucket residual = `token_usage` − Σ(assistant message buckets). The invariant: **summing the four token buckets across `TurnRecord.messages` (assistant + reconciliation) equals `token_usage` exactly**, for both Claude and Codex (Codex's stream is already complete after `_recover_subagent_tool_calls`, so its residual is usually 0 and no entry is emitted). This is what lets the evalboard SUM the message stream as the source of truth instead of reading a separate aggregate ("agent tokens"): `selectTokenTotals` returns the stream sum whenever a reconciliation entry is present, and the timeline renders it as its own row. It is agent-agnostic (booked at the single `EventCollector` seam), carries no cost (cost stays on `token_usage`), and is excluded from generation/turn counts and the cost simulator. The LiteLLM open-weight actual-cost join (`litellm_cost.apply_actual_cost`) deliberately writes cost at the TURN level only (`token_usage.total_cost_usd` = the real OpenRouter bill) plus the per-call `TurnRecord.provider_call_costs` audit record; it does NOT touch the message token buckets, so `EventCollector` stays the single writer and this invariant holds on every backend. The Python `token_usage`/`total_token_usage` aggregate is unchanged and still authoritative for budget/judges/reports.
- **sandbox isolation**: Tasks that don't need MCP servers should set `setting_sources: []` in their `agent:` block to isolate the sandbox from the host project's CLAUDE.md and settings. Without this, the host project's CLAUDE.md (often 20 KB+) is injected into every API call, inflating cache-creation tokens and cost significantly.
- **Run-time caps (non-criterion enforcement)**: `TaskDefinition.run_limits` (`RunLimits` model) is the single namespace for all run-time caps — `max_turns` / `task_timeout` / `turn_timeout` (structural) and `max_input_tokens` / `max_output_tokens` / `max_total_tokens` / `max_usd` (cumulative budget). Token/USD breaches abort with `FinalStatus.TOKEN_BUDGET_EXCEEDED` or `COST_BUDGET_EXCEEDED` (both `category == "failed"`). Structural caps are set from the CLI via `-D run_limits.max_turns=…` / `-D run_limits.task_timeout=…` / `-D run_limits.turn_timeout=…` (field-merged into `run_limits`); budget caps via `-D run_limits.max_usd=…` etc. or YAML. Layered config uses field-merge — a variant block overrides individual keys without replacing the task's block.
- **Early stop on criterion (opt-in)**: `run_limits.stop_early` (default off) ends a single-shot Claude run early once the run's **armed** criteria are decided, so a raised `max_turns` isn't wasted on the smoke flavor. A criterion is armed by `stop_when: pass|fail|decided|auto`; only criteria that can decide from a partial trajectory may arm (non-empty `live_stop_polarities` ClassVar + `live_verdict` override — currently `skill_triggered`, `command_executed`; CE025 keeps the two consistent). `decided` arms **both** polarities; `auto` arms whichever polarities **this instance** can decide — the value for dataset-fanned criteria whose positive/distractor role flips per row. Stop rule: the pass-stop fires when every **pass-armed** criterion live-passes (fail-armed distractors are not required to pass; zero pass-armed ⇒ never pass-stops); the fail-stop fires on the first fail-armed live-fail but is **deferred while any pass-armed criterion is undecided** — a distractor misfire must not truncate a positive row's recall signal, so the latched misfire fires once the positives resolve (or the run continues to the cap). A fail-stop is therefore verdict-preserving; a pass-stop can miss a *later* distractor misfire, so authoritative P/R/F1 comes from a `stop_early: false` run. Driven by `orchestration/early_stop.py::EarlyStopWatcher` through the Claude agent's cooperative `should_stop` seam (tool-call granularity, no SIGKILL); live verdicts only *trigger* the stop — the standard `check_all_async` on the frozen trajectory is authoritative. An early-stopped run gates on the **armed subset** (`EvaluationResult.armed_criteria_passed`); a completed run gates on the full set. Every unsupported use is a hard error at resolution (plan *and* run), and a runtime verdict bug **fails open** to a full run. Surfaces: `EarlyStopInfo`, report notes/badges, `stopped_early` run.json rows, `EarlyStopped`/`EarlyStopReason` telemetry dims. Worked rationale: docs/TASK_DEFINITION_GUIDE.md § `stop_early`. Defaults off ⇒ behavior byte-for-behavior unchanged.
- **Run-time caps (non-criterion enforcement)**: `TaskDefinition.run_limits` (`RunLimits` model) is the single namespace for all *task-level* run-time caps — `max_turns` / `task_timeout` / `turn_timeout` (structural) and `max_input_tokens` / `max_output_tokens` / `max_total_tokens` / `max_usd` (cumulative budget). Token/USD breaches abort with `FinalStatus.TOKEN_BUDGET_EXCEEDED` or `COST_BUDGET_EXCEEDED` (both `category == "failed"`). Structural caps are set from the CLI via `-D run_limits.max_turns=…` / `-D run_limits.task_timeout=…` / `-D run_limits.turn_timeout=…` (field-merged into `run_limits`); budget caps via `-D run_limits.max_usd=…` etc. or YAML. Layered config uses field-merge — a variant block overrides individual keys without replacing the task's block. The one *per-criterion* cap, `max_steps_to_decide`, deliberately lives on `LiveSuccessCriterion` instead (see below) — the watcher must attribute a decision-step-budget breach to a specific criterion, which `RunLimits` (task-scoped, criterion-agnostic) cannot express.
- **Early stop on criterion (opt-in)**: `run_limits.stop_early` (default off) ends a single-shot Claude run early once the run's **armed** criteria are decided, so a raised `max_turns` isn't wasted on the smoke flavor. A criterion is armed by `stop_when: pass|fail|decided|auto`; only criteria that can decide from a partial trajectory may arm — "is this criterion type live-observable" is `models.LiveSuccessCriterion` subclassing (currently `skill_triggered`, `command_executed`), the single source of truth `validate_early_stop`/`EarlyStopWatcher` check directly via `isinstance`; each subclass implements the abstract, checker-independent `live_decidable_polarities()` (a pure function of its own fields) alongside the checker's `live_verdict` override, and lint rule CE025 (`tests/test_custom_lint.py::TestCE025LiveVerdictConsistency`, a registry-based whole-tree check, not a per-file AST rule) keeps the two paired. `decided` arms **both** polarities; `auto` arms whichever polarities **this instance** can decide — the value for dataset-fanned criteria whose positive/distractor role flips per row. Stop rule is weighted, not strict-boolean: `run_limits.stop_early_gate_threshold` (default `1.0`, reproducing pre-weighting behavior byte-for-byte) is the minimum weighted score (`Σ weight·score / Σ weight` over the armed subset) required to pass; a fail-stop fires once the armed subset's **ceiling** (best case for everything still undecided) can no longer reach the threshold, a pass-stop once the pass-armed subset's **floor** (worst case) already meets it — both **deferred while any pass-armed criterion is undecided**, so a distractor misfire never truncates a positive row's recall signal before the positives resolve (or the run continues to the cap). A fail-stop is therefore verdict-preserving; a pass-stop can miss a *later* distractor misfire, so authoritative P/R/F1 comes from a `stop_early: false` run. A per-criterion `max_steps_to_decide` (on `LiveSuccessCriterion` only, requires `stop_when`) caps tool-call steps spent still undecided — cumulative across retry attempts of the same turn — before `EarlyStopReason.DECISION_BUDGET_EXCEEDED` force-fails the run outright, bypassing the weighted gate (nothing to weigh a criterion that never decided against). Driven by `orchestration/early_stop.py::EarlyStopWatcher` through the Claude agent's cooperative `should_stop` seam (tool-call granularity, no SIGKILL); live verdicts only *trigger* the stop — the standard `check_all_async` on the frozen trajectory is authoritative. An early-stopped run gates on the **armed subset** via the weighted `EvaluationResult.armed_criteria_passed`; a run that completes naturally (even with `stop_early: true`) gates on the full set via the strict-AND `all_criteria_passed` — weight magnitude only forgives under the former, so the weighted gate is contingent on the watcher itself firing, not solely on the configured threshold. Every unsupported use is a hard error at resolution (plan *and* run), and a runtime verdict bug **fails open** to a full run. Surfaces: `EarlyStopInfo` (incl. `gate_threshold` at stop time), report notes/badges, `stopped_early` run.json rows, `EarlyStopped`/`EarlyStopReason` telemetry dims. Worked rationale: docs/TASK_DEFINITION_GUIDE.md § `stop_early`. Defaults off ⇒ behavior byte-for-behavior unchanged.

## Success Criteria (14 types)

Expand Down
12 changes: 10 additions & 2 deletions docs/EXTENDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,8 +210,16 @@ Notes:
`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.
- For **early stop**, make your criterion model subclass `LiveSuccessCriterion`
(`models/criteria.py`) instead of `BaseSuccessCriterion`, implement its
abstract `live_decidable_polarities()` (a pure function of the criterion's
own fields — no `turn_records`, no checker instance), and override the
checker's `live_verdict(...)`. `LiveSuccessCriterion` subclassing is the
single source of truth for "is this criterion type live-observable" —
`validate_early_stop`/`EarlyStopWatcher` check `isinstance(c,
LiveSuccessCriterion)` directly, no separate checker-side flag. A lint rule
(`tests/test_custom_lint.py::TestCE025LiveVerdictConsistency`) keeps the
model subclassing and the checker's `live_verdict` override paired.

> A duplicate `criterion_type` **overwrites** the earlier checker with a warning (not
> a hard error, unlike agents) — keep type strings unique.
Expand Down
7 changes: 5 additions & 2 deletions docs/REPORT_SCHEMA.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,10 +188,13 @@ backend), `num_turns`, `max_turns_exhausted`,
### EarlyStopInfo

Present (non-`null`) iff the run stopped early — there is no separate boolean.
Fields: `reason` (`criterion_passed` / `criterion_failed`),
Fields: `reason` (`criterion_passed` / `criterion_failed` /
`decision_budget_exceeded` — the last forces `FinalStatus.FAILURE` outright,
bypassing the weighted gate),
`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`.
`elapsed_seconds`, `turns_remaining_at_stop`, `gate_threshold` (the
`run_limits.stop_early_gate_threshold` in effect for this stop; default `1.0`).

---

Expand Down
66 changes: 61 additions & 5 deletions docs/TASK_DEFINITION_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,7 @@ run_limits:
| `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). |
| `stop_early_gate_threshold` | `1.0` | `[0.0, 1.0]` (but `> 0.0` is enforced at resolution when `stop_early: true`) | Minimum weighted score over the armed subset required to gate as a pass. 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
Expand Down Expand Up @@ -376,11 +377,14 @@ Semantics:
(e.g. `stop_when: pass` alongside a `max_count`, or `auto` on an instance that
can decide neither) is likewise a hard error at resolution, not a silent full
run.
- **Verdict.** An early-stopped run is gated on the **armed subset only**; the
non-armed criteria become **advisory** and are clearly marked (report badge +
per-criterion note + `stopped_early` row). A run that completes naturally is
gated on the **full** set, as always. This is what lets one file serve both a
`smoke` flavor (`stop_early: true`) and an `e2e` flavor (`stop_early: false`) —
- **Verdict.** Any task armed for early-stop (`stop_early: true`) is gated on
the **armed subset only** — the non-armed criteria become **advisory** and
are clearly marked (report badge + per-criterion note + `stopped_early`
row when the watcher actually fired) — whether or not the watcher actually
cut the run short; one task config maps to one gate semantic. Only a task
that never armed `stop_early` at all is gated on the **full** set, as
always. This is what lets one file serve both a `smoke` flavor
(`stop_early: true`) and an `e2e` flavor (`stop_early: false`) —
see [AB_EXPERIMENTS.md](AB_EXPERIMENTS.md). Verdict parity between the flavors
is one-sided: a **fail-stop** is verdict-preserving (the deferral above
guarantees every pass-armed signal was allowed to resolve first), but a
Expand All @@ -390,6 +394,57 @@ Semantics:
authoritative precision/recall belongs on the `stop_early: false` run.
- **Fail-safe.** A live-verdict bug **fails open** to a full run (logged loudly) —
it can never silently disable a criterion or cause a false early stop.
- **Weighting.** `run_limits.stop_early_gate_threshold` (default `1.0`) is the
minimum weighted score (`Σ weight·score / Σ weight`, over the armed subset)
required to gate as a pass — both for the post-hoc verdict and for the live
stop rule itself. A fail-stop fires once the armed subset's **ceiling** (best
case: every still-undecided or already-passed criterion ends up scoring 1.0,
every live-failed one scores 0) can no longer reach the threshold — the gate
is mathematically guaranteed to fail regardless of how the trajectory
continues. A pass-stop fires once the pass-armed subset's **floor** (worst
case: every still-undecided one scores 0) already meets it. At the default
`1.0` both bounds collapse to the pre-weighting rules above exactly (any
single armed criterion's live-fail already drops the ceiling below 1.0, and
the floor only reaches 1.0 once every pass-armed criterion has actually
passed) — lowering it lets a low-weight armed criterion's failure be absorbed
without truncating the run, at the cost of the gate becoming a genuine
weighted average rather than a strict AND. **The armed weighted gate applies
whenever `stop_early: true` is set — one task config, one gate semantic —
regardless of whether the watcher actually fired a stop.** A task armed for
early-stop that instead completes naturally (the agent finishes, or
`max_turns` is hit, before the bound ever trips) is gated on the *same*
weighted armed-subset formula as an actual early stop, not the full-run
`all_criteria_passed`; only a task that never armed `stop_early` at all uses
the strict full-set gate. Each armed criterion's own `pass_threshold` still
decides whether it individually passed (converted to a binary 1.0/0.0
before weighting) — only the combination rule (weighted average vs strict
AND) changes, which is what makes the `gate_threshold=1.0` default an exact
equivalence with the pre-weighting `all(...)` rule.
- **Decision-step budget.** `max_steps_to_decide` (per armed criterion, only
on `skill_triggered` / `command_executed`, requires `stop_when`) caps how
many tool-call steps that criterion may spend still **undecided** before the
run gives up on it:

```yaml
success_criteria:
- type: skill_triggered
description: "date-teller must activate within 5 steps"
skill_name: date-teller
expected_skill: date-teller
stop_when: pass
max_steps_to_decide: 5
```

Once the cap is exceeded (checked AFTER the normal fail-/pass-stop checks
each round, so a criterion that decides on that very step is never
penalized), the watcher fires `reason: decision_budget_exceeded` and the run
is forced to `FinalStatus.FAILURE` outright — bypassing
`stop_early_gate_threshold`'s weighted gate entirely, since a criterion that
never reached a verdict has nothing meaningful to weigh against the others.
`None` (default) = no cap; the run relies solely on `run_limits.max_turns`.
The step count is **cumulative across every retry attempt** of the turn —
including an attempt that crashed or timed out before this criterion's own
investigation even began — so size the budget with that headroom in mind.

Observability (every early-stopped run is flagged everywhere so analysis never
compares a truncated run against a full one):
Expand Down Expand Up @@ -535,6 +590,7 @@ All criteria share these fields:
| `weight` | 1.0 | Relative importance for weighted score. `0` = **informational**: excluded from both the score and the pass/fail gate |
| `pass_threshold` | 0.9 | Minimum score (0.0–1.0) to pass |
| `stop_when` | `null` | Arms this criterion for early stop (`pass`/`fail`/`decided`/`auto`); requires `run_limits.stop_early: true` and an observable criterion type (`skill_triggered`, `command_executed`). `auto` arms whichever polarity this instance can decide (for dataset-fanned criteria whose positive/distractor role flips per row). See [`stop_early`](#stop_early-opt-in-early-stop). |
| `max_steps_to_decide` | `null` | **Only on live-observable criteria** (`skill_triggered`, `command_executed`) — requires `stop_when` to be set. Caps the tool-call steps this armed criterion may spend still undecided before the run gives up and force-fails. See [`stop_early`](#stop_early-opt-in-early-stop). |

**Scoring types:**
- **Binary** (1.0 or 0.0): `file_exists`, `run_command`, `file_matches_regex`, `classification_match`, `skill_triggered`
Expand Down
Loading
Loading