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,153 @@
1
+ """Aggregate command — (re)build run.json + run.md from finalized task.json files.
2
+
3
+ The standalone aggregation step: where `coder-eval run` folds the live batch into
4
+ run.json/run.md at the end of execution, this re-aggregates the same artifacts
5
+ afterwards from the finalized task.json files already on disk — for a run dir
6
+ whose top-level run.json is missing or stale (e.g. after recovering or combining
7
+ run dirs). It reuses the exact builder a live run uses (`build_run_summary`), so
8
+ the counts and version chip are identical.
9
+ """
10
+
11
+ import json
12
+ from datetime import datetime, timedelta
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ import typer
17
+ from pydantic import ValidationError
18
+
19
+ from ..models import SkippedTask, TaskResult
20
+ from .console import console
21
+
22
+
23
+ def aggregate_command(
24
+ run_dir: Path = typer.Argument( # noqa: B008
25
+ ...,
26
+ help="Run directory holding finalized task.json files (e.g. runs/2026-06-22_14-32-27).",
27
+ exists=True,
28
+ file_okay=False,
29
+ ),
30
+ output_dir: Path | None = typer.Option( # noqa: B008
31
+ None,
32
+ "--output",
33
+ "-o",
34
+ help="Directory to write run.json/run.md into instead of run_dir (e.g. a merged output dir).",
35
+ file_okay=False,
36
+ ),
37
+ ) -> None:
38
+ """(Re)build run.json + run.md by aggregating the finalized task.json files under a run dir.
39
+
40
+ Rebuilds the run-level summary only. Per-suite rollups (suite.json/suite.md) and
41
+ experiment reports (experiment.json/experiment.md) that a live ``run`` produces are
42
+ NOT rebuilt — the per-row suite/variant grouping they need is not recoverable from
43
+ task.json alone.
44
+
45
+ Examples:
46
+ # Rebuild a run's summary in place
47
+ coder-eval aggregate runs/2026-06-22_14-32-27
48
+
49
+ # Aggregate a combined dir's task results into a fresh summary
50
+ coder-eval aggregate runs/combined -o runs/combined
51
+ """
52
+ from ..orchestration.batch import build_run_summary, recover_task_results, write_run_summary
53
+
54
+ results = recover_task_results(run_dir)
55
+ if not results:
56
+ console.print(f"[red]Error: no finalized task.json files found under {run_dir}[/red]")
57
+ console.print("\n[dim]Hint: this aggregates a finished run — use 'coder-eval run' to create one.[/dim]")
58
+ raise typer.Exit(1)
59
+
60
+ out_dir = output_dir or run_dir
61
+
62
+ # tags / source-path / run-level fields are inputs (static task metadata), not
63
+ # results, so carry them from an existing run.json when present — reading them
64
+ # from a stale summary is safe. Absent → empty maps + a window derived from the
65
+ # recovered results.
66
+ task_tags, task_paths, prior = _read_prior_metadata(run_dir)
67
+ start_time, end_time = _resolve_window(results, prior)
68
+ skipped = _recover_skipped_tasks(prior)
69
+
70
+ summary = build_run_summary(
71
+ out_dir.name,
72
+ results,
73
+ start_time,
74
+ end_time,
75
+ task_tags,
76
+ task_paths=task_paths,
77
+ max_parallel=int(prior.get("max_parallel", 1) or 1),
78
+ skipped_tasks=skipped,
79
+ )
80
+ write_run_summary(summary, out_dir)
81
+ console.print(
82
+ f"[green][OK][/green] Aggregated {summary.tasks_run} task(s) "
83
+ + f"({summary.tasks_succeeded} ok / {summary.tasks_failed} fail / {summary.tasks_error} err) "
84
+ + f"→ {out_dir / 'run.json'}"
85
+ )
86
+ console.print(
87
+ "[dim]Note: run-level summary only — per-suite (suite.json/suite.md) and "
88
+ + "experiment (experiment.json/experiment.md) rollups are not rebuilt.[/dim]"
89
+ )
90
+
91
+
92
+ def _read_prior_metadata(run_dir: Path) -> tuple[dict[str, list[str]], dict[str, str], dict[str, Any]]:
93
+ """Pull per-task tags/source-paths + run-level fields from an existing run.json.
94
+
95
+ Returns ``(task_tags, task_paths, run_meta)`` — empty maps and ``{}`` when there
96
+ is no readable run.json (a fresh dir, or one assembled purely from task.json).
97
+ """
98
+ try:
99
+ prior = json.loads((run_dir / "run.json").read_text(encoding="utf-8"))
100
+ except (OSError, ValueError):
101
+ return {}, {}, {}
102
+ if not isinstance(prior, dict):
103
+ return {}, {}, {}
104
+ task_tags: dict[str, list[str]] = {}
105
+ task_paths: dict[str, str] = {}
106
+ for row in prior.get("task_results", []):
107
+ if not isinstance(row, dict):
108
+ continue
109
+ task_id = row.get("task_id")
110
+ if not task_id:
111
+ continue
112
+ if isinstance(row.get("tags"), list):
113
+ task_tags[task_id] = row["tags"]
114
+ if isinstance(row.get("task_path"), str):
115
+ task_paths[task_id] = row["task_path"]
116
+ return task_tags, task_paths, prior
117
+
118
+
119
+ def _recover_skipped_tasks(prior: dict[str, Any]) -> list[SkippedTask]:
120
+ """Reconstruct the skipped-task carry-over from a prior run.json, per entry.
121
+
122
+ The prior summary is untrusted (possibly stale / hand-edited / older-schema), so a
123
+ malformed entry must drop rather than abort the rebuild — mirroring the rest of
124
+ ``_read_prior_metadata``'s degrade-to-empty stance.
125
+ """
126
+ recovered: list[SkippedTask] = []
127
+ for entry in prior.get("skipped_tasks", []):
128
+ if not isinstance(entry, dict):
129
+ continue
130
+ try:
131
+ recovered.append(SkippedTask.model_validate(entry))
132
+ except ValidationError:
133
+ console.print(f"[yellow]Dropping malformed skipped_tasks entry from prior run.json: {entry}[/yellow]")
134
+ return recovered
135
+
136
+
137
+ def _resolve_window(results: list[TaskResult], prior: dict[str, Any]) -> tuple[datetime, datetime]:
138
+ """Determine the run's (start, end) for total_duration_seconds.
139
+
140
+ Prefer the prior run.json's timestamps (the real wall-clock); otherwise derive a
141
+ best-effort window from the recovered results' start times + durations. ``results``
142
+ is always non-empty here (the command errors out earlier on an empty recovery) and
143
+ ``EvaluationResult.started_at`` is a required field, so a start time always exists.
144
+ """
145
+ start_raw, end_raw = prior.get("start_time"), prior.get("end_time")
146
+ if isinstance(start_raw, str) and isinstance(end_raw, str):
147
+ try:
148
+ return datetime.fromisoformat(start_raw), datetime.fromisoformat(end_raw)
149
+ except ValueError:
150
+ pass
151
+ start = min(r.result.started_at for r in results)
152
+ end = max(r.result.started_at + timedelta(seconds=r.duration) for r in results)
153
+ return start, max(end, start)
@@ -0,0 +1,7 @@
1
+ """Shared console instance for CLI commands."""
2
+
3
+ from rich.console import Console
4
+
5
+
6
+ # Single console instance shared across all CLI commands
7
+ console = Console()
@@ -0,0 +1,164 @@
1
+ """Evaluate command - run criteria against a directory without an agent."""
2
+
3
+ import asyncio
4
+ from pathlib import Path
5
+
6
+ import typer
7
+
8
+ from ..logging_config import setup_logging
9
+ from ..models import (
10
+ AgentKind,
11
+ EvaluationResult,
12
+ FinalStatus,
13
+ PreservationMode,
14
+ TemplateDirSource,
15
+ parse_agent_config,
16
+ )
17
+ from ..orchestration.task_loader import load_task
18
+ from ..orchestrator import Orchestrator
19
+ from ..sandbox import Sandbox
20
+ from .console import console
21
+ from .run_helpers import prepare_run_directory
22
+
23
+
24
+ def evaluate_command(
25
+ task_file: Path = typer.Argument( # noqa: B008
26
+ ...,
27
+ help="Path to task YAML file",
28
+ exists=True,
29
+ ),
30
+ work_dir: Path = typer.Argument( # noqa: B008
31
+ ...,
32
+ help="Directory containing the code to evaluate",
33
+ exists=True,
34
+ file_okay=False,
35
+ dir_okay=True,
36
+ ),
37
+ verbose: bool = typer.Option(
38
+ False,
39
+ "--verbose",
40
+ "-v",
41
+ help="Enable verbose (DEBUG level) logging",
42
+ ),
43
+ preserve: bool = typer.Option(
44
+ True,
45
+ "--preserve/--no-preserve",
46
+ "-p/-P",
47
+ help="Move sandbox artifacts to run directory (default: preserve). The temp sandbox is always removed.",
48
+ ),
49
+ run_dir: Path | None = typer.Option( # noqa: B008
50
+ None,
51
+ "--run-dir",
52
+ help="Custom run directory (default: auto-generated timestamped directory in runs/)",
53
+ ),
54
+ ) -> None:
55
+ """Evaluate criteria against a directory without running an agent.
56
+
57
+ Runs the success criteria defined in a task against a work directory.
58
+ Artifacts are saved to a run directory when --preserve is used.
59
+
60
+ Examples:
61
+ coder-eval evaluate tasks/hello.yaml ./my_solution
62
+ coder-eval evaluate tasks/test.yaml /path/to/code --preserve
63
+ coder-eval evaluate tasks/test.yaml /path/to/code --run-dir ./my_eval_run
64
+ """
65
+ setup_logging(verbose=verbose)
66
+
67
+ console.print("\n[bold]Evaluating Criteria[/bold]\n")
68
+
69
+ try:
70
+ task, source_yaml = load_task(task_file)
71
+ except Exception as e:
72
+ console.print(f"[red]✗ Failed to load task:[/red] {e}")
73
+ raise typer.Exit(1) from e
74
+
75
+ # Evaluate-only mode bypasses experiment resolution + CLI overrides, so
76
+ # `agent` may be None or `agent.type` may be unset for tasks that defer
77
+ # those to the experiment / CLI layers. The orchestrator only uses
78
+ # `agent.type` for result labeling here (no agent is created), so a
79
+ # default is safe.
80
+
81
+ if task.agent is None:
82
+ task.agent = parse_agent_config(type=AgentKind.CLAUDE_CODE)
83
+ elif task.agent.type is None:
84
+ task.agent = parse_agent_config(**{**task.agent.model_dump(exclude_unset=True), "type": AgentKind.CLAUDE_CODE})
85
+
86
+ if not work_dir.is_dir():
87
+ console.print(f"[red]✗ Work directory is not a directory:[/red] {work_dir}")
88
+ raise typer.Exit(1)
89
+
90
+ try:
91
+ prepared_run_dir = prepare_run_directory(run_dir)
92
+ except Exception as e:
93
+ console.print(f"[red]✗ Failed to prepare run directory:[/red] {e}")
94
+ raise typer.Exit(1) from e
95
+
96
+ # Build a sandbox pre-loaded with the work_dir contents, then run evaluate-only
97
+ sandbox_config = task.sandbox.model_copy(deep=True)
98
+ template_source = TemplateDirSource(path=str(work_dir.resolve()))
99
+ if sandbox_config.template_sources:
100
+ sandbox_config.template_sources = [template_source, *sandbox_config.template_sources]
101
+ else:
102
+ sandbox_config.template_sources = [template_source]
103
+
104
+ task_dir = task_file.parent.resolve()
105
+ sandbox = Sandbox(sandbox_config, task_id=task.task_id, task_dir=task_dir)
106
+
107
+ async def _setup_and_run() -> EvaluationResult:
108
+ await asyncio.to_thread(sandbox.setup)
109
+ orchestrator = Orchestrator(
110
+ task=task,
111
+ run_dir=prepared_run_dir,
112
+ preservation_mode=PreservationMode.MOVE_ON_WRITE if preserve else PreservationMode.NONE,
113
+ task_file=task_file,
114
+ sandbox=sandbox,
115
+ variant_id="evaluate",
116
+ source_yaml=source_yaml,
117
+ )
118
+ return await orchestrator.run()
119
+
120
+ result = asyncio.run(_setup_and_run())
121
+
122
+ # Display results
123
+ console.print("[bold]Criteria Results:[/bold]\n")
124
+
125
+ criteria_results = result.success_criteria_results or []
126
+ if len(criteria_results) != len(task.success_criteria):
127
+ console.print(
128
+ f"[red]✗ Result count mismatch: got {len(criteria_results)}, expected {len(task.success_criteria)}[/red]"
129
+ )
130
+ raise typer.Exit(1)
131
+
132
+ for criterion, cr in zip(task.success_criteria, criteria_results, strict=True):
133
+ status = "[green]✓[/green]" if cr.score >= criterion.pass_threshold else "[red]✗[/red]"
134
+ console.print(f"{status} {cr.criterion_type}")
135
+ console.print(f" [dim]{cr.description}[/dim]")
136
+ console.print(f" [dim]Score: {cr.score:.2f}[/dim]")
137
+ if cr.details:
138
+ console.print(f" [dim]Details: {cr.details}[/dim]")
139
+ if cr.error:
140
+ console.print(f" [red]Error: {cr.error}[/red]")
141
+ console.print()
142
+
143
+ passed = sum(
144
+ 1 for cr, c in zip(criteria_results, task.success_criteria, strict=True) if cr.score >= c.pass_threshold
145
+ )
146
+ total = len(task.success_criteria)
147
+ failed = total - passed
148
+
149
+ console.print("[bold]Summary:[/bold]")
150
+ console.print(f" Passed: {passed}/{total}")
151
+ console.print(f" Failed: {failed}/{total}")
152
+ console.print(f"\n[dim]Run directory: {prepared_run_dir}[/dim]")
153
+ if result.sandbox_path:
154
+ console.print(f"[dim]Artifacts: {result.sandbox_path}[/dim]")
155
+
156
+ if result.final_status == FinalStatus.ERROR:
157
+ console.print(f"\n[red]✗ Evaluation error: {result.error_message}[/red]")
158
+ raise typer.Exit(1)
159
+ elif failed == 0:
160
+ console.print("\n[green]All criteria passed! ✓[/green]")
161
+ raise typer.Exit(0)
162
+ else:
163
+ console.print(f"\n[red]{failed} criterion/criteria failed.[/red]")
164
+ raise typer.Exit(1)
@@ -0,0 +1,152 @@
1
+ """Plan command - validate task files without executing."""
2
+
3
+ import warnings
4
+ from pathlib import Path
5
+
6
+ import typer
7
+
8
+ from ..models.tasks import UnknownTaskFieldWarning
9
+ from ..orchestration.task_loader import load_task
10
+ from .console import console
11
+ from .run_helpers import discover_default_tasks
12
+ from .utils import check_api_keys, check_tools
13
+
14
+
15
+ def plan_command(
16
+ task_files: list[Path] | None = typer.Argument( # noqa: B008
17
+ None,
18
+ help="Path(s) to task YAML file(s) to validate. Defaults to all tasks/ recursively.",
19
+ ),
20
+ experiment: Path | None = typer.Option( # noqa: B008
21
+ None,
22
+ "--experiment",
23
+ "-e",
24
+ help="Experiment definition YAML (default: experiments/default.yaml)",
25
+ ),
26
+ ) -> None:
27
+ """Validate task files without executing (dry-run).
28
+
29
+ When no TASK_FILES are provided, all .yaml files under tasks/ are discovered recursively.
30
+
31
+ This command checks:
32
+ - Task file syntax and schema validity
33
+ - Required CLI tools are available (claude, uv)
34
+ - API keys are configured
35
+ - Task configuration is reasonable
36
+
37
+ When an experiment is provided (or experiments/default.yaml exists),
38
+ also shows experiment info and resolved agent configs per variant.
39
+
40
+ Unknown top-level fields on TaskDefinition currently soft-warn
41
+ (see _warn_on_unknown_fields in models/tasks.py) — those warnings
42
+ surface inline under each task as ⚠ notices, without failing the run.
43
+
44
+ Examples:
45
+ coder-eval plan
46
+ coder-eval plan tasks/hello_date.yaml
47
+ coder-eval plan tasks/*.yaml
48
+ coder-eval plan tasks/*.yaml -e experiments/model-comparison.yaml
49
+ """
50
+ # Default to discovering all tasks under tasks/ when none provided
51
+ resolved_task_files = task_files if task_files else discover_default_tasks()
52
+
53
+ console.print("\n[bold]Task Validation (Dry-Run)[/bold]\n")
54
+
55
+ # Check required tools
56
+ check_tools()
57
+
58
+ # Check API keys
59
+ check_api_keys()
60
+
61
+ # Lazy import to avoid circular dependency at module level
62
+ from ..orchestration.experiment import DEFAULT_EXPERIMENT_PATH, load_experiment, resolve_task_for_variant
63
+
64
+ # Always load experiment (defaults to experiments/default.yaml)
65
+ exp_path = experiment if isinstance(experiment, Path) else DEFAULT_EXPERIMENT_PATH
66
+ try:
67
+ exp_def = load_experiment(exp_path)
68
+ if exp_path == DEFAULT_EXPERIMENT_PATH:
69
+ default_exp = exp_def
70
+ elif DEFAULT_EXPERIMENT_PATH.exists():
71
+ default_exp = load_experiment(DEFAULT_EXPERIMENT_PATH)
72
+ else:
73
+ default_exp = exp_def # fall back to custom as its own baseline
74
+ except Exception as e:
75
+ console.print(f"[red]Failed to load experiment ({exp_path}): {e}[/red]")
76
+ raise typer.Exit(1) from e
77
+
78
+ # Show experiment info
79
+ console.print("[bold cyan]Experiment[/bold cyan]")
80
+ console.print(f" [dim]ID: {exp_def.experiment_id}[/dim]")
81
+ if exp_def.description:
82
+ console.print(f" [dim]Description: {exp_def.description}[/dim]")
83
+ console.print(
84
+ f" [dim]Variants ({len(exp_def.variants)}): {', '.join(v.variant_id for v in exp_def.variants)}[/dim]"
85
+ )
86
+ if exp_def.defaults and exp_def.defaults.agent:
87
+ console.print(f" [dim]Default agent config: {exp_def.defaults.agent}[/dim]")
88
+ console.print()
89
+
90
+ # Validate each task file
91
+ all_valid = True
92
+ for task_file in resolved_task_files:
93
+ try:
94
+ # Capture warnings so unknown-field UnknownTaskFieldWarnings
95
+ # (emitted by TaskDefinition._warn_on_unknown_fields while the
96
+ # top-level schema stays in soft-launch mode) surface inline
97
+ # below \u2014 they don't fail the run, but they're visible to the
98
+ # author and to any CI log scraper. Other DeprecationWarnings
99
+ # raised during load (legacy-timing migrations, pydantic,
100
+ # transitive libs) are re-emitted through warnings.showwarning
101
+ # so they still reach stderr instead of getting swallowed.
102
+ with warnings.catch_warnings(record=True) as caught:
103
+ warnings.simplefilter("always", DeprecationWarning)
104
+ task, _source_yaml = load_task(task_file)
105
+
106
+ console.print(f"[green]\u2713[/green] {task_file.name}")
107
+ console.print(f" [dim]Task ID: {task.task_id}[/dim]")
108
+
109
+ # Handle optional agent field. Phase 3 made agent.type optional —
110
+ # tasks may now defer it to experiment defaults / --type.
111
+ if task.agent is None:
112
+ console.print(" [dim]Agent: N/A (resolved from experiment)[/dim]")
113
+ elif task.agent.type is None:
114
+ console.print(" [dim]Agent type: (deferred to experiment / --type)[/dim]")
115
+ else:
116
+ console.print(f" [dim]Agent: {task.agent.type}[/dim]")
117
+
118
+ console.print(f" [dim]Success criteria: {len(task.success_criteria)}[/dim]")
119
+
120
+ # Surface unknown-field warnings as inline notices (non-blocking;
121
+ # catches stale top-level fields like max_iterations / llm_reviewer
122
+ # that the soft-launch validator otherwise drops silently). Match
123
+ # by category, not message text, so a reworded warning string
124
+ # doesn't silently break this rendering. Anything else captured
125
+ # gets re-emitted to stderr so non-target deprecations stay visible.
126
+ for w in caught:
127
+ if issubclass(w.category, UnknownTaskFieldWarning):
128
+ console.print(f" [yellow]⚠[/yellow] [yellow]{w.message}[/yellow]")
129
+ else:
130
+ warnings.showwarning(w.message, w.category, w.filename, w.lineno)
131
+
132
+ # Show resolved agent per variant
133
+ for variant in exp_def.variants:
134
+ try:
135
+ resolved, _lineage, _ = resolve_task_for_variant(default_exp, task, exp_def, variant)
136
+ agent_type = str(resolved.agent.type) if resolved.agent else "unknown"
137
+ agent_model = resolved.agent.model if resolved.agent else None
138
+ model_str = f" ({agent_model})" if agent_model else ""
139
+ console.print(f" [dim]Variant '{variant.variant_id}': {agent_type}{model_str}[/dim]")
140
+ except Exception as e:
141
+ console.print(f" [red]Variant '{variant.variant_id}': resolution failed - {e}[/red]")
142
+
143
+ except Exception as e:
144
+ console.print(f"[red]\u2717[/red] {task_file.name}")
145
+ console.print(f" [red]Error: {e}[/red]")
146
+ all_valid = False
147
+
148
+ if all_valid:
149
+ console.print("\n[green]All tasks are valid![/green]")
150
+ else:
151
+ console.print("\n[red]Some tasks have errors.[/red]")
152
+ raise typer.Exit(1)
@@ -0,0 +1,121 @@
1
+ """Report command - display or export evaluation results."""
2
+
3
+ from pathlib import Path
4
+
5
+ import typer
6
+ from rich.markdown import Markdown
7
+
8
+ from ..models import EvaluationResult
9
+ from ..reports import ReportGenerator
10
+ from ..reports_html import write_task_html
11
+ from .console import console
12
+
13
+
14
+ def report_command(
15
+ run_dir: Path = typer.Argument( # noqa: B008
16
+ ...,
17
+ help="Path to a run directory (e.g., runs/latest or runs/2025-10-10_20-57-30)",
18
+ exists=True,
19
+ ),
20
+ output_file: Path | None = typer.Option( # noqa: B008
21
+ None,
22
+ "--output",
23
+ "-o",
24
+ help="Output file (default: display markdown to stdout).",
25
+ ),
26
+ report_format: str = typer.Option(
27
+ "md",
28
+ "--format",
29
+ "-f",
30
+ help="Output format: 'md' (default markdown), 'html' (render task.json files as HTML).",
31
+ ),
32
+ ) -> None:
33
+ """Display or export a run report.
34
+
35
+ The run command automatically generates a report during execution.
36
+ This command allows you to view or export that report later.
37
+
38
+ Examples:
39
+ # Display latest run report
40
+ coder-eval report runs/latest
41
+
42
+ # Export specific run to markdown file
43
+ coder-eval report runs/2025-10-10_20-57-30 -o summary.md
44
+
45
+ # Re-generate HTML reports for every task.json under a run dir
46
+ coder-eval report runs/latest --format html
47
+ """
48
+ fmt = report_format.lower()
49
+ if fmt not in ("md", "html"):
50
+ console.print(f"[red]Error: unknown --format '{report_format}' (expected 'md' or 'html')[/red]")
51
+ raise typer.Exit(1)
52
+
53
+ if fmt == "html":
54
+ _regenerate_html_reports(run_dir, output_file)
55
+ return
56
+
57
+ try:
58
+ report_md, source_path = ReportGenerator.load_from_run_dir(run_dir)
59
+ console.print(f"[dim]Reading report from {source_path}[/dim]\n")
60
+
61
+ except FileNotFoundError as e:
62
+ console.print(f"[red]Error: {e}[/red]")
63
+ console.print("\n[dim]Hint: Use 'coder-eval run' to create evaluation runs.[/dim]")
64
+ raise typer.Exit(1) from e
65
+
66
+ # Output report
67
+ if output_file:
68
+ output_file.write_text(report_md, encoding="utf-8")
69
+ console.print(f"[green][OK]Report saved to {output_file}[/green]")
70
+ else:
71
+ console.print(Markdown(report_md))
72
+
73
+
74
+ def _regenerate_html_reports(run_dir: Path, output_file: Path | None) -> None:
75
+ """Regenerate task-level HTML reports from every task.json under run_dir.
76
+
77
+ If `output_file` is provided and exactly one task.json is found, the
78
+ report is written to that file. Otherwise each task.html is written
79
+ next to its task.json.
80
+ """
81
+ task_json_paths = sorted(run_dir.rglob("task.json"))
82
+ if not task_json_paths:
83
+ console.print(f"[red]Error: no task.json files found under {run_dir}[/red]")
84
+ raise typer.Exit(1)
85
+
86
+ if output_file and len(task_json_paths) > 1:
87
+ msg = (
88
+ f"[red]Error: --output targets a single file but {len(task_json_paths)} task.json files "
89
+ + "were found. Omit --output to regenerate all reports in place.[/red]"
90
+ )
91
+ console.print(msg)
92
+ raise typer.Exit(1)
93
+
94
+ from ..evaluation.judge_persistence import load_judge_transcripts
95
+
96
+ written: list[Path] = []
97
+ failed: list[Path] = []
98
+ for task_json in task_json_paths:
99
+ try:
100
+ result = EvaluationResult.model_validate_json(task_json.read_text(encoding="utf-8"))
101
+ except Exception as e:
102
+ console.print(f"[yellow]Skipping {task_json}: {e}[/yellow]")
103
+ failed.append(task_json)
104
+ continue
105
+ # Pull any spilled judge transcripts back onto the in-memory result so
106
+ # the HTML re-render shows the same disclosures the original run produced.
107
+ load_judge_transcripts(result, task_json.parent)
108
+ target = output_file if output_file else task_json.with_name("task.html")
109
+ written_path = write_task_html(result, target)
110
+ if written_path is None:
111
+ console.print(f"[red]Failed to write HTML for {task_json}[/red]")
112
+ failed.append(task_json)
113
+ continue
114
+ written.append(written_path)
115
+
116
+ for p in written:
117
+ console.print(f"[green][OK]Wrote {p}[/green]")
118
+ console.print(f"[green]Generated {len(written)} HTML report(s)[/green]")
119
+ if failed:
120
+ console.print(f"[red]{len(failed)} task(s) failed[/red]")
121
+ raise typer.Exit(1)