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.
- coder_eval/.gitattributes +2 -0
- coder_eval/__init__.py +3 -0
- coder_eval/agent.py +302 -0
- coder_eval/agents/__init__.py +41 -0
- coder_eval/agents/_logging.py +89 -0
- coder_eval/agents/antigravity_agent.py +811 -0
- coder_eval/agents/claude_code_agent.py +1701 -0
- coder_eval/agents/codex_agent.py +2055 -0
- coder_eval/agents/noop_agent.py +100 -0
- coder_eval/agents/registry.py +163 -0
- coder_eval/agents/watchdog.py +116 -0
- coder_eval/analysis.py +88 -0
- coder_eval/cli/__init__.py +87 -0
- coder_eval/cli/aggregate_command.py +153 -0
- coder_eval/cli/console.py +7 -0
- coder_eval/cli/evaluate_command.py +164 -0
- coder_eval/cli/plan_command.py +152 -0
- coder_eval/cli/report_command.py +121 -0
- coder_eval/cli/run_command.py +686 -0
- coder_eval/cli/run_helpers.py +137 -0
- coder_eval/cli/run_task_internal_command.py +210 -0
- coder_eval/cli/utils.py +37 -0
- coder_eval/config.py +212 -0
- coder_eval/criteria/__init__.py +163 -0
- coder_eval/criteria/_classification_aggregate.py +106 -0
- coder_eval/criteria/agent_judge.py +425 -0
- coder_eval/criteria/base.py +248 -0
- coder_eval/criteria/classification_match.py +107 -0
- coder_eval/criteria/command_executed.py +181 -0
- coder_eval/criteria/commands_efficiency.py +73 -0
- coder_eval/criteria/file_check.py +119 -0
- coder_eval/criteria/file_contains.py +85 -0
- coder_eval/criteria/file_exists.py +45 -0
- coder_eval/criteria/file_matches_regex.py +86 -0
- coder_eval/criteria/json_check.py +184 -0
- coder_eval/criteria/llm_judge.py +260 -0
- coder_eval/criteria/reference_comparison.py +130 -0
- coder_eval/criteria/run_command.py +220 -0
- coder_eval/criteria/skill_triggered.py +111 -0
- coder_eval/criteria/uipath_eval.py +223 -0
- coder_eval/errors/__init__.py +26 -0
- coder_eval/errors/agent.py +26 -0
- coder_eval/errors/budget.py +28 -0
- coder_eval/errors/categories.py +205 -0
- coder_eval/errors/categorization.py +240 -0
- coder_eval/errors/executor.py +124 -0
- coder_eval/errors/judge.py +12 -0
- coder_eval/errors/retry.py +211 -0
- coder_eval/errors/timeout.py +63 -0
- coder_eval/evaluation/__init__.py +10 -0
- coder_eval/evaluation/checker.py +260 -0
- coder_eval/evaluation/judge_anthropic.py +55 -0
- coder_eval/evaluation/judge_bedrock.py +114 -0
- coder_eval/evaluation/judge_context.py +497 -0
- coder_eval/evaluation/judge_models.py +53 -0
- coder_eval/evaluation/judge_persistence.py +291 -0
- coder_eval/evaluation/judge_usage.py +50 -0
- coder_eval/evaluation/sub_agent.py +231 -0
- coder_eval/evaluation/summaries.py +48 -0
- coder_eval/evaluation/verdict_tool.py +213 -0
- coder_eval/formatting.py +191 -0
- coder_eval/isolation/__init__.py +15 -0
- coder_eval/isolation/docker_runner.py +1253 -0
- coder_eval/logging_config.py +399 -0
- coder_eval/models/__init__.py +337 -0
- coder_eval/models/agent_config.py +373 -0
- coder_eval/models/container_paths.py +26 -0
- coder_eval/models/criteria.py +942 -0
- coder_eval/models/enums.py +121 -0
- coder_eval/models/experiment.py +314 -0
- coder_eval/models/judge.py +90 -0
- coder_eval/models/judge_defaults.py +11 -0
- coder_eval/models/limits.py +89 -0
- coder_eval/models/merge_strategy.py +132 -0
- coder_eval/models/mutations.py +95 -0
- coder_eval/models/results.py +787 -0
- coder_eval/models/routing.py +160 -0
- coder_eval/models/sandbox.py +371 -0
- coder_eval/models/tasks.py +631 -0
- coder_eval/models/telemetry.py +454 -0
- coder_eval/models/templates.py +108 -0
- coder_eval/orchestration/__init__.py +13 -0
- coder_eval/orchestration/batch.py +690 -0
- coder_eval/orchestration/config.py +111 -0
- coder_eval/orchestration/config_merge.py +431 -0
- coder_eval/orchestration/evaluation.py +94 -0
- coder_eval/orchestration/experiment.py +837 -0
- coder_eval/orchestration/overrides.py +206 -0
- coder_eval/orchestration/task_loader.py +437 -0
- coder_eval/orchestrator.py +2078 -0
- coder_eval/path_utils.py +79 -0
- coder_eval/plugins.py +81 -0
- coder_eval/pricing.py +169 -0
- coder_eval/py.typed +0 -0
- coder_eval/reports.py +1066 -0
- coder_eval/reports_experiment.py +761 -0
- coder_eval/reports_html.py +1659 -0
- coder_eval/reports_stats.py +250 -0
- coder_eval/resources/__init__.py +137 -0
- coder_eval/resources/default_experiment.yaml +85 -0
- coder_eval/resources/default_ignore_patterns.yaml +60 -0
- coder_eval/resources/tags.yaml +55 -0
- coder_eval/sandbox.py +1127 -0
- coder_eval/scoring/__init__.py +11 -0
- coder_eval/scoring/ast_similarity.py +38 -0
- coder_eval/scoring/complexity.py +115 -0
- coder_eval/scoring/quality.py +154 -0
- coder_eval/scoring/signature_similarity.py +57 -0
- coder_eval/scoring/similarity.py +103 -0
- coder_eval/scoring/token_similarity.py +44 -0
- coder_eval/simulation/__init__.py +27 -0
- coder_eval/simulation/termination.py +88 -0
- coder_eval/simulation/user_simulator.py +324 -0
- coder_eval/streaming/__init__.py +44 -0
- coder_eval/streaming/callbacks.py +57 -0
- coder_eval/streaming/collector.py +193 -0
- coder_eval/streaming/events.py +198 -0
- coder_eval/streaming/renderers.py +248 -0
- coder_eval/streaming/wire.py +115 -0
- coder_eval/telemetry.py +407 -0
- coder_eval/utils.py +517 -0
- coder_eval-0.8.2.dist-info/METADATA +242 -0
- coder_eval-0.8.2.dist-info/RECORD +127 -0
- coder_eval-0.8.2.dist-info/WHEEL +4 -0
- coder_eval-0.8.2.dist-info/entry_points.txt +5 -0
- coder_eval-0.8.2.dist-info/licenses/LICENSE +201 -0
- 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,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)
|