coder-eval 0.8.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. coder_eval/.gitattributes +2 -0
  2. coder_eval/__init__.py +3 -0
  3. coder_eval/agent.py +302 -0
  4. coder_eval/agents/__init__.py +41 -0
  5. coder_eval/agents/_logging.py +89 -0
  6. coder_eval/agents/antigravity_agent.py +811 -0
  7. coder_eval/agents/claude_code_agent.py +1701 -0
  8. coder_eval/agents/codex_agent.py +2055 -0
  9. coder_eval/agents/noop_agent.py +100 -0
  10. coder_eval/agents/registry.py +163 -0
  11. coder_eval/agents/watchdog.py +116 -0
  12. coder_eval/analysis.py +88 -0
  13. coder_eval/cli/__init__.py +87 -0
  14. coder_eval/cli/aggregate_command.py +153 -0
  15. coder_eval/cli/console.py +7 -0
  16. coder_eval/cli/evaluate_command.py +164 -0
  17. coder_eval/cli/plan_command.py +152 -0
  18. coder_eval/cli/report_command.py +121 -0
  19. coder_eval/cli/run_command.py +686 -0
  20. coder_eval/cli/run_helpers.py +137 -0
  21. coder_eval/cli/run_task_internal_command.py +210 -0
  22. coder_eval/cli/utils.py +37 -0
  23. coder_eval/config.py +212 -0
  24. coder_eval/criteria/__init__.py +163 -0
  25. coder_eval/criteria/_classification_aggregate.py +106 -0
  26. coder_eval/criteria/agent_judge.py +425 -0
  27. coder_eval/criteria/base.py +248 -0
  28. coder_eval/criteria/classification_match.py +107 -0
  29. coder_eval/criteria/command_executed.py +181 -0
  30. coder_eval/criteria/commands_efficiency.py +73 -0
  31. coder_eval/criteria/file_check.py +119 -0
  32. coder_eval/criteria/file_contains.py +85 -0
  33. coder_eval/criteria/file_exists.py +45 -0
  34. coder_eval/criteria/file_matches_regex.py +86 -0
  35. coder_eval/criteria/json_check.py +184 -0
  36. coder_eval/criteria/llm_judge.py +260 -0
  37. coder_eval/criteria/reference_comparison.py +130 -0
  38. coder_eval/criteria/run_command.py +220 -0
  39. coder_eval/criteria/skill_triggered.py +111 -0
  40. coder_eval/criteria/uipath_eval.py +223 -0
  41. coder_eval/errors/__init__.py +26 -0
  42. coder_eval/errors/agent.py +26 -0
  43. coder_eval/errors/budget.py +28 -0
  44. coder_eval/errors/categories.py +205 -0
  45. coder_eval/errors/categorization.py +240 -0
  46. coder_eval/errors/executor.py +124 -0
  47. coder_eval/errors/judge.py +12 -0
  48. coder_eval/errors/retry.py +211 -0
  49. coder_eval/errors/timeout.py +63 -0
  50. coder_eval/evaluation/__init__.py +10 -0
  51. coder_eval/evaluation/checker.py +260 -0
  52. coder_eval/evaluation/judge_anthropic.py +55 -0
  53. coder_eval/evaluation/judge_bedrock.py +114 -0
  54. coder_eval/evaluation/judge_context.py +497 -0
  55. coder_eval/evaluation/judge_models.py +53 -0
  56. coder_eval/evaluation/judge_persistence.py +291 -0
  57. coder_eval/evaluation/judge_usage.py +50 -0
  58. coder_eval/evaluation/sub_agent.py +231 -0
  59. coder_eval/evaluation/summaries.py +48 -0
  60. coder_eval/evaluation/verdict_tool.py +213 -0
  61. coder_eval/formatting.py +191 -0
  62. coder_eval/isolation/__init__.py +15 -0
  63. coder_eval/isolation/docker_runner.py +1253 -0
  64. coder_eval/logging_config.py +399 -0
  65. coder_eval/models/__init__.py +337 -0
  66. coder_eval/models/agent_config.py +373 -0
  67. coder_eval/models/container_paths.py +26 -0
  68. coder_eval/models/criteria.py +942 -0
  69. coder_eval/models/enums.py +121 -0
  70. coder_eval/models/experiment.py +314 -0
  71. coder_eval/models/judge.py +90 -0
  72. coder_eval/models/judge_defaults.py +11 -0
  73. coder_eval/models/limits.py +89 -0
  74. coder_eval/models/merge_strategy.py +132 -0
  75. coder_eval/models/mutations.py +95 -0
  76. coder_eval/models/results.py +787 -0
  77. coder_eval/models/routing.py +160 -0
  78. coder_eval/models/sandbox.py +371 -0
  79. coder_eval/models/tasks.py +631 -0
  80. coder_eval/models/telemetry.py +454 -0
  81. coder_eval/models/templates.py +108 -0
  82. coder_eval/orchestration/__init__.py +13 -0
  83. coder_eval/orchestration/batch.py +690 -0
  84. coder_eval/orchestration/config.py +111 -0
  85. coder_eval/orchestration/config_merge.py +431 -0
  86. coder_eval/orchestration/evaluation.py +94 -0
  87. coder_eval/orchestration/experiment.py +837 -0
  88. coder_eval/orchestration/overrides.py +206 -0
  89. coder_eval/orchestration/task_loader.py +437 -0
  90. coder_eval/orchestrator.py +2078 -0
  91. coder_eval/path_utils.py +79 -0
  92. coder_eval/plugins.py +81 -0
  93. coder_eval/pricing.py +169 -0
  94. coder_eval/py.typed +0 -0
  95. coder_eval/reports.py +1066 -0
  96. coder_eval/reports_experiment.py +761 -0
  97. coder_eval/reports_html.py +1659 -0
  98. coder_eval/reports_stats.py +250 -0
  99. coder_eval/resources/__init__.py +137 -0
  100. coder_eval/resources/default_experiment.yaml +85 -0
  101. coder_eval/resources/default_ignore_patterns.yaml +60 -0
  102. coder_eval/resources/tags.yaml +55 -0
  103. coder_eval/sandbox.py +1127 -0
  104. coder_eval/scoring/__init__.py +11 -0
  105. coder_eval/scoring/ast_similarity.py +38 -0
  106. coder_eval/scoring/complexity.py +115 -0
  107. coder_eval/scoring/quality.py +154 -0
  108. coder_eval/scoring/signature_similarity.py +57 -0
  109. coder_eval/scoring/similarity.py +103 -0
  110. coder_eval/scoring/token_similarity.py +44 -0
  111. coder_eval/simulation/__init__.py +27 -0
  112. coder_eval/simulation/termination.py +88 -0
  113. coder_eval/simulation/user_simulator.py +324 -0
  114. coder_eval/streaming/__init__.py +44 -0
  115. coder_eval/streaming/callbacks.py +57 -0
  116. coder_eval/streaming/collector.py +193 -0
  117. coder_eval/streaming/events.py +198 -0
  118. coder_eval/streaming/renderers.py +248 -0
  119. coder_eval/streaming/wire.py +115 -0
  120. coder_eval/telemetry.py +407 -0
  121. coder_eval/utils.py +517 -0
  122. coder_eval-0.8.2.dist-info/METADATA +242 -0
  123. coder_eval-0.8.2.dist-info/RECORD +127 -0
  124. coder_eval-0.8.2.dist-info/WHEEL +4 -0
  125. coder_eval-0.8.2.dist-info/entry_points.txt +5 -0
  126. coder_eval-0.8.2.dist-info/licenses/LICENSE +201 -0
  127. coder_eval-0.8.2.dist-info/licenses/NOTICE +18 -0
@@ -0,0 +1,686 @@
1
+ """Run command - execute evaluation tasks."""
2
+
3
+ import asyncio
4
+ import logging
5
+ import os
6
+ import sys
7
+ from collections.abc import Callable
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import click
12
+ import typer
13
+ from tqdm import tqdm
14
+
15
+ from ..config import settings
16
+ from ..logging_config import setup_logging
17
+ from ..models import PreservationMode, ResolvedTask, RunSummary, TaskResult
18
+ from ..orchestration.config import BatchRunConfig
19
+ from ..path_utils import create_latest_symlink, format_task_log_id
20
+ from ..streaming.callbacks import CompositeStreamCallback
21
+ from ..streaming.renderers import LoggingStreamRenderer, RichStreamRenderer
22
+ from .console import console
23
+ from .run_helpers import (
24
+ discover_default_tasks,
25
+ expand_task_files,
26
+ prepare_run_directory,
27
+ print_execution_mode,
28
+ print_execution_summary,
29
+ )
30
+
31
+
32
+ def _resolve_experiment_path(experiment: Path | None) -> Path | None:
33
+ """Resolve an experiment path, supporting bare names like 'model-comparison'.
34
+
35
+ Resolution order:
36
+ 1. None → None (use default experiment)
37
+ 2. Path exists as-is → use it
38
+ 3. experiments/{name}.yaml exists → use it
39
+ 4. experiments/{name} exists → use it
40
+ 5. Raise typer.BadParameter with available experiments
41
+ """
42
+ if experiment is None:
43
+ return None
44
+ if experiment.exists():
45
+ return experiment
46
+
47
+ # Try resolving bare name under experiments/ (project-root-relative, not CWD-relative).
48
+ # Path: cli/run_command.py → cli/ → coder_eval/ → src/ → project_root (4 levels).
49
+ # NOTE: This assumes a source checkout. If installed into site-packages, this won't resolve.
50
+ # That's acceptable since experiments/ lives in the repo, not the installed package.
51
+ _project_root = Path(__file__).resolve().parent.parent.parent.parent
52
+ experiments_dir = _project_root / "experiments"
53
+ for candidate in [
54
+ experiments_dir / f"{experiment}.yaml",
55
+ experiments_dir / f"{experiment}.yml",
56
+ experiments_dir / str(experiment),
57
+ ]:
58
+ if candidate.exists():
59
+ return candidate
60
+
61
+ # Build helpful error message listing available experiments
62
+ available: list[str] = []
63
+ if experiments_dir.is_dir():
64
+ available = sorted(p.stem for p in experiments_dir.glob("*.yaml") if p.stem != "default")
65
+ hint = f" Available: {', '.join(available)}" if available else ""
66
+ raise typer.BadParameter(f"Experiment not found: {experiment}.{hint}")
67
+
68
+
69
+ def _build_overrides(
70
+ *,
71
+ model: str | None,
72
+ driver: str | None,
73
+ set_overrides: list[str],
74
+ ) -> dict[str, Any]:
75
+ """Translate the surviving alias flags (``--model`` / ``--driver``) +
76
+ ``-D``/``--set`` entries into one validated override map (dotted path ->
77
+ typed value).
78
+
79
+ Alias flags and ``-D`` share the same engine path. A path set by both an
80
+ alias and ``-D`` (or by two ``-D`` entries) is a hard error so they never
81
+ silently last-win against each other. Every resulting path is validated
82
+ against the schema; ``OverrideError`` is wrapped into ``typer.BadParameter``
83
+ at this CLI boundary. All other task-config knobs (permission mode, turn
84
+ limits, timeouts, tools, plugins, SDK options) are expressed via ``-D``.
85
+ """
86
+ from ..orchestration.config_merge import MergeError, validate_paths
87
+ from ..orchestration.overrides import OverrideError, parse_override
88
+
89
+ overrides: dict[str, Any] = {}
90
+ sources: dict[str, str] = {} # path -> originating flag, for collision messages
91
+
92
+ def _add_alias(path: str, value: Any, flag: str) -> None:
93
+ overrides[path] = value
94
+ sources[path] = flag
95
+
96
+ if model is not None:
97
+ _add_alias("agent.model", model, "--model")
98
+ if driver is not None:
99
+ _add_alias("sandbox.driver", driver, "--driver")
100
+
101
+ # -D / --set entries (hard error on collision with an alias or another -D).
102
+ for raw in set_overrides:
103
+ try:
104
+ path, value = parse_override(raw)
105
+ except OverrideError as e:
106
+ raise typer.BadParameter(str(e)) from e
107
+ if path in sources:
108
+ prior = sources[path]
109
+ if prior == "-D":
110
+ raise typer.BadParameter(f"{path!r} set by -D more than once; specify it once")
111
+ raise typer.BadParameter(f"{path!r} set by both {prior} and -D; specify it once")
112
+ overrides[path] = value
113
+ sources[path] = "-D"
114
+
115
+ # Validate every path against the resolved-TaskDefinition schema (the same
116
+ # walk the resolver uses). MergeError carries the did-you-mean suggestion.
117
+ try:
118
+ validate_paths(list(overrides))
119
+ except MergeError as e:
120
+ raise typer.BadParameter(str(e)) from e
121
+
122
+ return overrides
123
+
124
+
125
+ def run_command(
126
+ task_files: list[Path] | None = typer.Argument( # noqa: B008
127
+ None,
128
+ help="Path(s) to task YAML file(s). Defaults to all tasks/ recursively.",
129
+ ),
130
+ preservation_mode: PreservationMode | None = typer.Option( # noqa: B008
131
+ None,
132
+ "--preservation-mode",
133
+ help=(
134
+ "How to persist each task's sandbox: NONE (delete), MOVE_ON_WRITE "
135
+ "(run in a tempdir, move into run_dir/artifacts), or DIRECT_WRITE "
136
+ "(run directly in run_dir/artifacts). Default is driver-derived — "
137
+ "docker → DIRECT_WRITE, else MOVE_ON_WRITE. Explicit value always wins."
138
+ ),
139
+ ),
140
+ run_dir: Path | None = typer.Option( # noqa: B008
141
+ None,
142
+ "--run-dir",
143
+ help="Custom run directory (default: auto-generated timestamped directory in runs/)",
144
+ ),
145
+ resume: bool = typer.Option(
146
+ False,
147
+ "--resume",
148
+ help=(
149
+ "Resume an interrupted run: skip tasks already finalized in --run-dir and "
150
+ "run only the rest, folding prior results into run.json. A task counts as "
151
+ "finalized once it has ANY final status — including FAILED/ERROR — so resume "
152
+ "does NOT retry failures (delete a task's task.json to force a re-run). "
153
+ "Requires --run-dir. A config mismatch (model/backend/flags) is warned, not "
154
+ "refused — the resumed tasks keep their original-config results, so the run "
155
+ "mixes configs; use a fresh --run-dir to keep configs separate."
156
+ ),
157
+ ),
158
+ max_parallel: int = typer.Option(
159
+ 1,
160
+ "--max-parallel",
161
+ "-j",
162
+ help="Maximum number of tasks to run concurrently (default: 1 = sequential)",
163
+ min=1,
164
+ ),
165
+ verbose: bool = typer.Option(
166
+ False,
167
+ "--verbose",
168
+ "-v",
169
+ help="Enable verbose (DEBUG level) logging",
170
+ ),
171
+ log_file: Path | None = typer.Option( # noqa: B008
172
+ None,
173
+ "--log-file",
174
+ help="Log to file in addition to console",
175
+ ),
176
+ tags: str | None = typer.Option(
177
+ None,
178
+ "--tags",
179
+ "-t",
180
+ help="Only run tasks matching any of these tags (comma-separated, e.g., 'smoke,golden')",
181
+ ),
182
+ exclude_tags: str | None = typer.Option(
183
+ None,
184
+ "--exclude-tags",
185
+ help="Skip tasks matching any of these tags (comma-separated, e.g., 'example,integration')",
186
+ ),
187
+ include_skipped: bool = typer.Option(
188
+ False,
189
+ "--include-skipped",
190
+ help=(
191
+ "Also run tasks marked `skip: true` in their YAML. Off by default so the "
192
+ "nightly/CI keep excluding them; use for on-demand / local runs of "
193
+ "quarantined or opt-in tasks."
194
+ ),
195
+ ),
196
+ agent_type: str | None = typer.Option(
197
+ None,
198
+ "--type",
199
+ "-T",
200
+ # Open string, not a closed click.Choice: the agent registry (incl. plugin
201
+ # kinds discovered at startup) is the source of truth, and it isn't populated
202
+ # at CLI-definition time. An unregistered kind fails at parse_agent_config with
203
+ # a clear "No agent registered for type ...; Registered kinds: [...]" message.
204
+ help="Override agent type for all tasks (e.g. 'claude-code', 'codex', or a plugin kind)",
205
+ ),
206
+ model: str | None = typer.Option(
207
+ None,
208
+ "--model",
209
+ "-m",
210
+ help="Override agent model for all tasks (e.g., claude-sonnet-4-20250514)",
211
+ ),
212
+ stream: str | None = typer.Option(
213
+ None,
214
+ "--stream",
215
+ "-s",
216
+ click_type=click.Choice(["full", "minimal"], case_sensitive=False),
217
+ help="Stream LLM events to terminal: 'full' or 'minimal' (turn-level only). Disables progress bar.",
218
+ ),
219
+ backend: str | None = typer.Option(
220
+ None,
221
+ "--backend",
222
+ "-b",
223
+ click_type=click.Choice(["direct", "bedrock"], case_sensitive=False),
224
+ help="API backend (default: from API_BACKEND env var)",
225
+ ),
226
+ experiment: Path | None = typer.Option( # noqa: B008
227
+ None,
228
+ "--experiment",
229
+ "-e",
230
+ help="Experiment definition YAML (default: experiments/default.yaml)",
231
+ ),
232
+ sample: int | None = typer.Option(
233
+ None,
234
+ "--sample",
235
+ help=(
236
+ "For dataset-backed tasks, use a random N-row sample "
237
+ "(fixed seed: reproducible, unbiased across paths). Cheap dataset smoke-test."
238
+ ),
239
+ min=1,
240
+ ),
241
+ sample_per_stratum: int | None = typer.Option(
242
+ None,
243
+ "--sample-per-stratum",
244
+ help=(
245
+ "For dataset-backed tasks, keep up to N rows per stratum (stratify_field, "
246
+ "default expected_skill) — a stratified sample that overrides the task's "
247
+ "dataset.sample_per_stratum without editing the YAML. Ignored when --sample is set. "
248
+ "Nondeterministic (re-draws each run) unless the task sets dataset.sample_seed."
249
+ ),
250
+ min=1,
251
+ ),
252
+ repeats: int | None = typer.Option(
253
+ None,
254
+ "--repeats",
255
+ help="Run each (task, variant) N times. Overrides experiment/variant `repeats:`. Must be >=1.",
256
+ min=1,
257
+ ),
258
+ # typer types this as str|None at signature level; click.Choice narrows
259
+ # the runtime value to {"tempdir","docker"}. BatchRunConfig.driver
260
+ # expects the Literal; the field validator accepts any str and the
261
+ # Choice constraint plus the experiment-layer Literal hint keep us safe.
262
+ driver: str | None = typer.Option(
263
+ None,
264
+ "--driver",
265
+ click_type=click.Choice(["tempdir", "docker"], case_sensitive=False),
266
+ help="Override sandbox driver for all tasks. 'docker' runs each task in a fresh container.",
267
+ ),
268
+ set_overrides: list[str] = typer.Option( # noqa: B008
269
+ [],
270
+ "--set",
271
+ "-D",
272
+ metavar="PATH=VALUE",
273
+ help=(
274
+ "Override any resolved task-config field under agent/run_limits/sandbox, "
275
+ "e.g. -D run_limits.max_turns=30 -D agent.permission_mode=plan "
276
+ "-D agent.sdk_options.effort=high -D sandbox.docker.network=none. "
277
+ "Repeatable. Validated against the schema. A path set by both an alias "
278
+ "and -D is an error; values are YAML-parsed (on/off/yes/no stay strings). "
279
+ "(--model and --driver are shorthand aliases for -D agent.model / "
280
+ "-D sandbox.driver.)"
281
+ ),
282
+ ),
283
+ ) -> None:
284
+ """Run evaluation tasks (optionally in parallel).
285
+
286
+ When no TASK_FILES are provided, all .yaml files under tasks/ are discovered recursively.
287
+
288
+ Sandboxes are preserved by default for debugging (driver-derived mode).
289
+ Use --preservation-mode NONE to clean up.
290
+
291
+ Examples:
292
+
293
+ coder-eval run
294
+
295
+ coder-eval run tasks/hello_date.yaml
296
+
297
+ coder-eval run tasks/*.yaml --preservation-mode NONE
298
+
299
+ coder-eval run tasks/*.yaml --run-dir ./my-custom-run
300
+
301
+ coder-eval run tasks/*.yaml --max-parallel 3
302
+
303
+ coder-eval run tasks/*.yaml --verbose --log-file debug.log
304
+
305
+ coder-eval run tasks/*.yaml --tags smoke
306
+
307
+ coder-eval run tasks/*.yaml --tags golden,basic --exclude-tags example
308
+ """
309
+ # --resume needs an explicit run dir to resume into (auto-generated dirs are always fresh).
310
+ if resume and run_dir is None:
311
+ raise typer.BadParameter("--resume requires --run-dir pointing at the run to continue.")
312
+
313
+ # Parse tag filters
314
+ include_tags = {t.strip() for t in tags.split(",") if t.strip()} if tags else None
315
+ exclude_tags_set = {t.strip() for t in exclude_tags.split(",") if t.strip()} if exclude_tags else None
316
+
317
+ # Translate the surviving alias flags + -D/--set into one validated override
318
+ # map (layer 5). All other task-config knobs are expressed via -D.
319
+ overrides = _build_overrides(
320
+ model=model,
321
+ driver=driver,
322
+ set_overrides=set_overrides,
323
+ )
324
+
325
+ # Override API backend if --backend was passed. The flag is shorthand for the
326
+ # API_BACKEND env var, so mirror it into os.environ as well: the docker driver
327
+ # forwards the backend into the container via the standard env passthrough
328
+ # (name-only `--env API_BACKEND`, which reads os.environ). A flag that only
329
+ # mutated `settings` would be dropped at the container boundary and the
330
+ # in-container Settings would silently default to DIRECT.
331
+ if backend is not None:
332
+ from coder_eval.models import ApiBackend
333
+
334
+ resolved_backend = ApiBackend(backend)
335
+ settings.api_backend = resolved_backend
336
+ os.environ["API_BACKEND"] = resolved_backend.value
337
+
338
+ # Setup logging before running tasks
339
+ log_level = settings.log_level
340
+ setup_logging(level=log_level, log_file=log_file, verbose=verbose)
341
+
342
+ # Default to discovering all tasks under tasks/ when none provided
343
+ resolved_task_files = task_files if task_files else discover_default_tasks()
344
+
345
+ # Resolve experiment path: bare names like "model-comparison" → experiments/model-comparison.yaml
346
+ resolved_experiment = _resolve_experiment_path(experiment)
347
+
348
+ # Run the async entry point
349
+ try:
350
+ asyncio.run(
351
+ _run_all_tasks(
352
+ resolved_task_files,
353
+ preservation_mode,
354
+ run_dir,
355
+ max_parallel,
356
+ include_tags,
357
+ exclude_tags_set,
358
+ agent_type,
359
+ overrides,
360
+ stream,
361
+ experiment_path=resolved_experiment,
362
+ max_rows=sample,
363
+ sample_per_stratum=sample_per_stratum,
364
+ repeats=repeats,
365
+ verbose=verbose,
366
+ resume=resume,
367
+ include_skipped=include_skipped,
368
+ )
369
+ )
370
+ except KeyboardInterrupt:
371
+ console.print("\n[yellow]Execution interrupted.[/yellow]")
372
+ raise typer.Exit(2) from None
373
+
374
+
375
+ async def _run_all_tasks(
376
+ task_files: list[Path],
377
+ preservation_mode: PreservationMode | None,
378
+ run_dir: Path | None,
379
+ max_parallel: int,
380
+ include_tags: set[str] | None = None,
381
+ exclude_tags: set[str] | None = None,
382
+ agent_type: str | None = None,
383
+ overrides: dict[str, Any] | None = None,
384
+ stream_mode: str | None = None,
385
+ experiment_path: Path | None = None,
386
+ max_rows: int | None = None,
387
+ sample_per_stratum: int | None = None,
388
+ repeats: int | None = None,
389
+ verbose: bool = False,
390
+ resume: bool = False,
391
+ include_skipped: bool = False,
392
+ ) -> None:
393
+ """Async entry point for running all tasks (optionally in parallel).
394
+
395
+ Tasks are resolved through the experiment layer (defaulting to
396
+ experiments/default.yaml) and executed via run_batch.
397
+
398
+ Args:
399
+ task_files: List of task file paths or glob patterns
400
+ preservation_mode: Sandbox preservation mode, or None for the driver-derived default
401
+ run_dir: Custom run directory (or None for auto-generated)
402
+ max_parallel: Maximum number of concurrent tasks
403
+ include_tags: Only run tasks matching any of these tags
404
+ exclude_tags: Skip tasks matching any of these tags
405
+ agent_type: Optional override for agent type (re-parses the union)
406
+ overrides: Generic layer-5 task-config overrides (path -> typed value)
407
+ from -D/--set and the bespoke flag aliases
408
+ stream_mode: Optional stream mode ('full' or 'minimal') for real-time output
409
+ experiment_path: Optional path to experiment YAML (default: experiments/default.yaml)
410
+ """
411
+ # Prepare run directory
412
+ run_dir = prepare_run_directory(run_dir)
413
+
414
+ # Create 'latest' symlink immediately so it's available during the run
415
+ if run_dir.parent == settings.runs_dir:
416
+ create_latest_symlink(settings.runs_dir, run_dir.name)
417
+
418
+ # Expand glob patterns and collect task files
419
+ all_task_files = expand_task_files(task_files)
420
+
421
+ # Configure batch execution
422
+ config = BatchRunConfig(
423
+ run_dir=run_dir,
424
+ max_parallel=max_parallel,
425
+ preservation_mode=preservation_mode,
426
+ include_tags=include_tags,
427
+ exclude_tags=exclude_tags,
428
+ agent_type=agent_type,
429
+ overrides=overrides or {},
430
+ max_rows=max_rows,
431
+ sample_per_stratum=sample_per_stratum,
432
+ repeats=repeats,
433
+ verbose=verbose,
434
+ include_skipped=include_skipped,
435
+ )
436
+
437
+ from ..telemetry import flush_telemetry, track_event
438
+
439
+ # TaskFileCount is the pre-expansion file count (dataset fan-out and variant
440
+ # resolution happen later); per-task counts are reconstructable from the
441
+ # CoderEval.Task.End events.
442
+ track_event(
443
+ "CoderEval.Run.Start",
444
+ {
445
+ "TaskFileCount": len(all_task_files),
446
+ "MaxParallel": max_parallel,
447
+ "AgentType": agent_type or "default",
448
+ "StreamMode": stream_mode or "none",
449
+ "Resume": resume,
450
+ "ExperimentProvided": experiment_path is not None,
451
+ },
452
+ )
453
+
454
+ try:
455
+ # Always run through experiment layer (defaults to experiments/default.yaml)
456
+ summary, failed_suite_gates = await _run_with_experiment(
457
+ all_task_files, config, experiment_path, stream_mode, max_parallel, resume=resume
458
+ )
459
+
460
+ # Aggregate task logs into run.log
461
+ from ..logging_config import aggregate_task_logs
462
+
463
+ aggregate_task_logs(run_dir)
464
+
465
+ # Print execution summary
466
+ print_execution_summary(run_dir, summary)
467
+ finally:
468
+ # Explicit flush before process exit (belt-and-suspenders with atexit).
469
+ # In a `finally` so it runs on the success path and on any raised
470
+ # exception, but never catches/swallows the typer.Exit decided below.
471
+ flush_telemetry()
472
+
473
+ # Exit with non-zero code if any tasks failed, errored, or any suite failed its thresholds.
474
+ if summary.tasks_failed > 0 or summary.tasks_error > 0 or failed_suite_gates > 0:
475
+ raise typer.Exit(1)
476
+
477
+
478
+ async def _run_with_callbacks(
479
+ execute_fn: Callable[..., Any],
480
+ task_count: int,
481
+ stream_mode: str | None,
482
+ ) -> Any:
483
+ """Run a batch execution function with streaming or progress bar callbacks.
484
+
485
+ Handles the shared logic of setting up either a streaming callback factory
486
+ (when --stream is enabled) or a tqdm progress bar (default mode).
487
+
488
+ Args:
489
+ execute_fn: Async callable that accepts keyword arguments
490
+ stream_callback_factory, on_task_complete, and on_batch_start.
491
+ task_count: Number of tasks (used for batch_mode detection).
492
+ stream_mode: Optional stream mode ('full' or 'minimal') for real-time output.
493
+
494
+ Returns:
495
+ Whatever execute_fn returns.
496
+ """
497
+ if stream_mode:
498
+ batch_mode = task_count > 1
499
+ rich_renderer = RichStreamRenderer(verbosity=stream_mode, batch_mode=batch_mode)
500
+ logging_renderer = LoggingStreamRenderer()
501
+ stream_callback_factory = lambda _task_id: CompositeStreamCallback([rich_renderer, logging_renderer]) # noqa: E731
502
+ return await execute_fn(stream_callback_factory=stream_callback_factory)
503
+
504
+ progress_bar: tqdm[Any] | None = None
505
+
506
+ def _on_batch_start(count: int) -> None:
507
+ nonlocal progress_bar
508
+ progress_bar = tqdm(total=count, desc="Tasks", unit="task", dynamic_ncols=True, disable=not sys.stderr.isatty())
509
+
510
+ def _on_task_complete(result: Any) -> None:
511
+ if progress_bar is None:
512
+ return
513
+ status = result.result.final_status
514
+ label = format_task_log_id(result.variant_id, result.task_id, result.replicate_index)
515
+ status_icon = status.icon
516
+ progress_bar.set_postfix_str(f"{status_icon} {label}")
517
+ progress_bar.update(1)
518
+
519
+ try:
520
+ result = await execute_fn(on_task_complete=_on_task_complete, on_batch_start=_on_batch_start)
521
+ finally:
522
+ if progress_bar is not None:
523
+ progress_bar.close()
524
+ return result
525
+
526
+
527
+ async def _run_with_experiment(
528
+ all_task_files: list[Path],
529
+ config: BatchRunConfig,
530
+ experiment_path: Path | None,
531
+ stream_mode: str | None,
532
+ max_parallel: int,
533
+ resume: bool = False,
534
+ ) -> tuple[RunSummary, int]:
535
+ """Run tasks through the experiment resolution layer.
536
+
537
+ Loads experiments, resolves task configs (all 5 layers), executes via
538
+ run_batch, and generates experiment reports.
539
+
540
+ Args:
541
+ all_task_files: Expanded list of task file paths.
542
+ config: Batch execution configuration.
543
+ experiment_path: Explicit experiment path or None for default.
544
+ stream_mode: Optional stream mode for real-time output.
545
+ max_parallel: Maximum parallel tasks (for batch_mode detection).
546
+ resume: Skip tasks already finalized in the run dir, folding their prior
547
+ results back into the summary.
548
+
549
+ Returns:
550
+ RunSummary with aggregated results.
551
+ """
552
+ from ..orchestration.batch import (
553
+ clear_rerun_artifacts,
554
+ compute_run_fingerprint,
555
+ fingerprint_diff,
556
+ partition_for_resume,
557
+ read_run_fingerprint,
558
+ run_batch,
559
+ write_run_fingerprint,
560
+ )
561
+ from ..orchestration.experiment import (
562
+ DEFAULT_EXPERIMENT_PATH,
563
+ aggregate_results,
564
+ load_experiment,
565
+ resolve_all_tasks,
566
+ ) # resolve_task_for_variant not needed here
567
+ from ..reports_experiment import ExperimentReportGenerator
568
+
569
+ # Load experiments (avoid double-loading when using default)
570
+ exp_path = experiment_path or DEFAULT_EXPERIMENT_PATH
571
+ try:
572
+ experiment = load_experiment(exp_path)
573
+ except (FileNotFoundError, ValueError) as e:
574
+ raise typer.BadParameter(f"Failed to load experiment '{exp_path}': {e}") from e
575
+ if exp_path == DEFAULT_EXPERIMENT_PATH:
576
+ default_experiment = experiment
577
+ elif DEFAULT_EXPERIMENT_PATH.exists():
578
+ try:
579
+ default_experiment = load_experiment(DEFAULT_EXPERIMENT_PATH)
580
+ except (FileNotFoundError, ValueError) as e:
581
+ raise typer.BadParameter(f"Failed to load default experiment '{DEFAULT_EXPERIMENT_PATH}': {e}") from e
582
+ else:
583
+ default_experiment = experiment # fall back to custom as its own baseline
584
+
585
+ # Resolve tasks through experiment layer (applies all 5 config layers).
586
+ # Layer-5 override failures (invalid -D value/path, sdk_options on a
587
+ # non-claude agent, the agent.type guard) and duplicate-task-id checks raise
588
+ # ValueError here; surface them as a clean CLI error instead of a traceback.
589
+ try:
590
+ resolved, skipped = resolve_all_tasks(
591
+ task_files=all_task_files,
592
+ experiment=experiment,
593
+ default_experiment=default_experiment,
594
+ config=config,
595
+ experiment_file=exp_path,
596
+ )
597
+ except ValueError as e:
598
+ raise typer.BadParameter(str(e)) from e
599
+
600
+ if skipped:
601
+ console.print(
602
+ f"[yellow]⚠[/] {len(skipped)} task file(s) skipped "
603
+ + "(load errors or `skip: true` — see run.json `skipped_tasks` for reasons)"
604
+ )
605
+
606
+ # Warn (don't refuse) when a --resume config differs from the original run. The
607
+ # per-task path key (variant/task_id/NN) doesn't encode the run config, so resumed
608
+ # tasks keep their original-config results — surfacing the mismatch makes the
609
+ # resulting mixed-config run.json visible instead of silent. Best-effort and
610
+ # informational: a missing stamp (run predates this feature) is tolerated.
611
+ current_fingerprint = compute_run_fingerprint(
612
+ config, experiment.experiment_id, settings.api_backend.value, settings.bedrock_model
613
+ )
614
+ if resume:
615
+ prior_fingerprint = read_run_fingerprint(config.run_dir)
616
+ if prior_fingerprint is not None:
617
+ diffs = fingerprint_diff(prior_fingerprint, current_fingerprint)
618
+ if diffs:
619
+ detail = "; ".join(f"{k}: {old!r} → {new!r}" for k, (old, new) in sorted(diffs.items()))
620
+ console.print(
621
+ f"[yellow]⚠[/] --resume into {config.run_dir} but the run config changed ({detail}). "
622
+ + "Already-finalized tasks keep their original-config results, so this run mixes "
623
+ + "configs — use a fresh --run-dir to keep them separate."
624
+ )
625
+ write_run_fingerprint(config.run_dir, current_fingerprint)
626
+
627
+ # On --resume, peel off tasks already finalized in the run dir. They are not
628
+ # re-executed but are folded back into run.json (and all downstream reports)
629
+ # via prior_results so the summary covers the whole run. `resolved` stays the
630
+ # full set — suite rollups below need every task, run or not.
631
+ to_run: list[ResolvedTask] = resolved
632
+ prior_results: list[TaskResult] = []
633
+ prior_resolved: list[ResolvedTask] = []
634
+ if resume:
635
+ to_run, prior_results, prior_resolved = partition_for_resume(resolved)
636
+ # A re-run task re-executes from scratch, so any leftover artifacts (only
637
+ # DIRECT_WRITE writes them live; a container killed mid-run leaves partials)
638
+ # are stale and could let a file-based criterion pass on the old output.
639
+ cleared = clear_rerun_artifacts(to_run)
640
+ console.print(
641
+ f"[cyan]↻ Resume:[/] {len(prior_results)} task(s) already complete, "
642
+ + f"running {len(to_run)} remaining"
643
+ + (f" (cleared {cleared} stale artifact dir(s))" if cleared else "")
644
+ )
645
+
646
+ # Print execution mode
647
+ print_execution_mode(len(to_run), max_parallel)
648
+
649
+ summary, task_results = await _run_with_callbacks(
650
+ execute_fn=lambda **kwargs: run_batch(
651
+ resolved_tasks=to_run,
652
+ config=config,
653
+ skipped_tasks=skipped,
654
+ prior_results=prior_results,
655
+ prior_resolved=prior_resolved,
656
+ **kwargs,
657
+ ),
658
+ task_count=len(to_run),
659
+ stream_mode=stream_mode,
660
+ )
661
+
662
+ # Generate experiment reports
663
+ experiment_result = aggregate_results(
664
+ experiment_id=experiment.experiment_id,
665
+ description=experiment.description,
666
+ variant_ids=[v.variant_id for v in experiment.variants],
667
+ task_results=task_results,
668
+ total_duration=summary.total_duration_seconds,
669
+ )
670
+ # Reports are written at run root level (no experiment_id subfolder)
671
+ ExperimentReportGenerator.write_reports(experiment_result, config.run_dir, experiment=experiment)
672
+
673
+ # Per-suite pass-rate rollups for dataset-backed tasks (no-op when none were used).
674
+ # Pass `resolved` through so suite_thresholds on each criterion can be evaluated.
675
+ from ..reports import write_suite_rollups
676
+
677
+ rollups = write_suite_rollups(config.run_dir, task_results, resolved_tasks=resolved)
678
+ failed_gates = [r for r in rollups if not r.passed]
679
+ if failed_gates:
680
+ logging.getLogger(__name__).warning(
681
+ "%d suite gate(s) failed thresholds: %s",
682
+ len(failed_gates),
683
+ ", ".join(f"{r.variant_id}/{r.suite_id}" for r in failed_gates),
684
+ )
685
+
686
+ return summary, len(failed_gates)