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,137 @@
|
|
|
1
|
+
"""Helper functions for the run command."""
|
|
2
|
+
|
|
3
|
+
import random
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from ..config import settings
|
|
9
|
+
from ..models import RunSummary
|
|
10
|
+
from ..path_utils import TASK_LOG_FILENAME, generate_run_id
|
|
11
|
+
from .console import console
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# Resolve tasks/ relative to project root — this is a repo-only feature.
|
|
15
|
+
# When running from a wheel install, users must provide explicit task file paths.
|
|
16
|
+
_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent
|
|
17
|
+
DEFAULT_TASKS_DIR = _PROJECT_ROOT / "tasks"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def discover_default_tasks() -> list[Path]:
|
|
21
|
+
"""Recursively find all .yaml task files under the default tasks/ directory.
|
|
22
|
+
|
|
23
|
+
This feature requires a source checkout — the tasks/ directory is not
|
|
24
|
+
shipped in the wheel. When running from an installed package, provide
|
|
25
|
+
explicit task file paths instead.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
Sorted list of task file paths.
|
|
29
|
+
|
|
30
|
+
Raises:
|
|
31
|
+
typer.Exit: If the tasks/ directory doesn't exist or contains no YAML files.
|
|
32
|
+
"""
|
|
33
|
+
if not DEFAULT_TASKS_DIR.is_dir():
|
|
34
|
+
console.print(f"[red]Default tasks directory not found: {DEFAULT_TASKS_DIR}[/red]")
|
|
35
|
+
console.print(
|
|
36
|
+
"[yellow]Hint: Zero-argument task discovery requires a source checkout. "
|
|
37
|
+
+ "Provide explicit task file paths instead: coder-eval run task1.yaml task2.yaml[/yellow]"
|
|
38
|
+
)
|
|
39
|
+
raise typer.Exit(1)
|
|
40
|
+
|
|
41
|
+
task_files = list(DEFAULT_TASKS_DIR.rglob("*.yaml"))
|
|
42
|
+
if not task_files:
|
|
43
|
+
console.print(f"[red]No .yaml files found in {DEFAULT_TASKS_DIR}[/red]")
|
|
44
|
+
raise typer.Exit(1)
|
|
45
|
+
|
|
46
|
+
random.shuffle(task_files)
|
|
47
|
+
console.print(f"[dim]Discovered {len(task_files)} task(s) from {DEFAULT_TASKS_DIR}[/dim]")
|
|
48
|
+
return task_files
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def prepare_run_directory(run_dir: Path | None) -> Path:
|
|
52
|
+
"""Create and prepare the run directory.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
run_dir: Custom run directory path, or None to auto-generate
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
Path to the prepared run directory
|
|
59
|
+
"""
|
|
60
|
+
if run_dir is None:
|
|
61
|
+
run_id = generate_run_id()
|
|
62
|
+
run_dir = settings.runs_dir / run_id
|
|
63
|
+
run_dir.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
|
|
65
|
+
console.print(f"[bold]Run directory:[/bold] {run_dir}\n")
|
|
66
|
+
return run_dir
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def expand_task_files(task_files: list[Path]) -> list[Path]:
|
|
70
|
+
"""Expand glob patterns and collect all task files.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
task_files: List of task file paths or glob patterns
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
List of resolved task file paths
|
|
77
|
+
|
|
78
|
+
Raises:
|
|
79
|
+
typer.Exit: If no task files are found
|
|
80
|
+
"""
|
|
81
|
+
all_task_files = []
|
|
82
|
+
for pattern in task_files:
|
|
83
|
+
if pattern.is_file():
|
|
84
|
+
all_task_files.append(pattern)
|
|
85
|
+
else:
|
|
86
|
+
# Try as glob pattern (supports ** for recursive matching)
|
|
87
|
+
if pattern.is_absolute():
|
|
88
|
+
all_task_files.extend(Path(pattern.anchor).glob(str(pattern.relative_to(pattern.anchor))))
|
|
89
|
+
else:
|
|
90
|
+
all_task_files.extend(Path().glob(str(pattern)))
|
|
91
|
+
|
|
92
|
+
if not all_task_files:
|
|
93
|
+
console.print("[red]No task files found![/red]")
|
|
94
|
+
raise typer.Exit(1)
|
|
95
|
+
|
|
96
|
+
random.shuffle(all_task_files)
|
|
97
|
+
return all_task_files
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def print_execution_mode(task_count: int, max_parallel: int) -> None:
|
|
101
|
+
"""Print execution mode information.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
task_count: Number of tasks to execute
|
|
105
|
+
max_parallel: Maximum parallel tasks
|
|
106
|
+
"""
|
|
107
|
+
console.print(f"\n[bold]Running {task_count} task(s)[/bold]")
|
|
108
|
+
if max_parallel > 1:
|
|
109
|
+
console.print(f"[dim]Max parallel tasks: {max_parallel}[/dim]\n")
|
|
110
|
+
else:
|
|
111
|
+
console.print("[dim]Mode: Sequential[/dim]\n")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def print_execution_summary(run_dir: Path, summary: RunSummary) -> None:
|
|
115
|
+
"""Print execution summary with results and log file locations.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
run_dir: Path to the run directory
|
|
119
|
+
summary: Run execution summary
|
|
120
|
+
"""
|
|
121
|
+
console.print(f"\n[bold green]Run complete:[/bold green] {run_dir}")
|
|
122
|
+
console.print(f"[bold]Results:[/bold] {summary.tasks_succeeded}/{summary.tasks_run} succeeded")
|
|
123
|
+
console.print(f"[dim]View report: open {run_dir / 'experiment.md'}[/dim]")
|
|
124
|
+
console.print(f"[dim]View report: uv run coder-eval report {run_dir}[/dim]")
|
|
125
|
+
|
|
126
|
+
# Print log file locations
|
|
127
|
+
console.print("\n[bold]Log Files:[/bold]")
|
|
128
|
+
run_log_path = run_dir / "experiment.log"
|
|
129
|
+
if run_log_path.exists():
|
|
130
|
+
console.print(f" Experiment log: {run_log_path}")
|
|
131
|
+
|
|
132
|
+
# Print task logs (find all task.log files under variant directories)
|
|
133
|
+
for task_log in sorted(run_dir.glob(f"**/{TASK_LOG_FILENAME}")):
|
|
134
|
+
# Dataset fan-out task_ids contain slashes, so render the full
|
|
135
|
+
# relative path rather than indexing fixed parent segments.
|
|
136
|
+
rel = task_log.parent.relative_to(run_dir).as_posix()
|
|
137
|
+
console.print(f" Task log ({rel}): {task_log}")
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Internal CLI subcommand executed inside the Docker container.
|
|
2
|
+
|
|
3
|
+
Not part of the public CLI surface -- the host's :class:`DockerRunner`
|
|
4
|
+
invokes it via ``docker run``. It loads the staged task + context from
|
|
5
|
+
``/work/input``, runs one full evaluation cycle in-process (driver=tempdir),
|
|
6
|
+
and writes ``task.json`` + ``task.html`` to ``/work/output``.
|
|
7
|
+
|
|
8
|
+
The container always exits 0 once ``task.json`` is written, even if the
|
|
9
|
+
task itself failed -- criterion failures are signaled via the final_status
|
|
10
|
+
field, not the container exit code. Setup failures (missing input,
|
|
11
|
+
malformed YAML) exit non-zero before producing task.json so the host can
|
|
12
|
+
distinguish them from task-level failures.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import contextlib
|
|
19
|
+
import json
|
|
20
|
+
import logging
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
import typer
|
|
24
|
+
|
|
25
|
+
from coder_eval.config import settings
|
|
26
|
+
from coder_eval.isolation.docker_runner import (
|
|
27
|
+
HEARTBEAT_FILENAME,
|
|
28
|
+
HEARTBEAT_STALE_SECONDS,
|
|
29
|
+
)
|
|
30
|
+
from coder_eval.logging_config import setup_logging
|
|
31
|
+
from coder_eval.models import (
|
|
32
|
+
CONTAINER_INPUT_DIR,
|
|
33
|
+
CONTAINER_OUTPUT_DIR,
|
|
34
|
+
CONTAINER_TASK_DIR,
|
|
35
|
+
ConfigLineageEntry,
|
|
36
|
+
PreservationMode,
|
|
37
|
+
)
|
|
38
|
+
from coder_eval.orchestration.task_loader import load_task
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def heartbeat_is_alive(current: str, last_counter: str, current_mtime: float, last_mtime: float) -> bool:
|
|
45
|
+
"""True when the heartbeat shows a fresh signal of life.
|
|
46
|
+
|
|
47
|
+
Counter advance OR mtime advance counts as alive. The mtime arm covers the empty-counter
|
|
48
|
+
startup race (host touch + delayed first write → both reads ""; mtime advanced); the counter
|
|
49
|
+
arm covers bind-mount mtime latency (macOS gRPC-FUSE/VirtioFS) where mtime lags the write.
|
|
50
|
+
"""
|
|
51
|
+
return bool(current and current != last_counter) or current_mtime > last_mtime
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def run_task_internal_command(
|
|
55
|
+
input_dir: Path = typer.Option( # noqa: B008
|
|
56
|
+
Path(CONTAINER_INPUT_DIR),
|
|
57
|
+
"--input",
|
|
58
|
+
help="Directory containing task.yaml and context.json (bind-mounted by host).",
|
|
59
|
+
),
|
|
60
|
+
output_dir: Path = typer.Option( # noqa: B008
|
|
61
|
+
Path(CONTAINER_OUTPUT_DIR),
|
|
62
|
+
"--output",
|
|
63
|
+
help="Directory to write task.json/task.html into (bind-mounted by host).",
|
|
64
|
+
),
|
|
65
|
+
task_dir: Path = typer.Option( # noqa: B008
|
|
66
|
+
Path(CONTAINER_TASK_DIR),
|
|
67
|
+
"--task-dir",
|
|
68
|
+
help="Original task directory mount (used to resolve relative template paths).",
|
|
69
|
+
),
|
|
70
|
+
verbose: bool = typer.Option(
|
|
71
|
+
False,
|
|
72
|
+
"--verbose",
|
|
73
|
+
"-v",
|
|
74
|
+
help="Enable verbose (DEBUG level) logging",
|
|
75
|
+
),
|
|
76
|
+
) -> None:
|
|
77
|
+
"""Run a single staged task inside the container."""
|
|
78
|
+
# Use the same logging path as the host CLI so LOG_LEVEL from the
|
|
79
|
+
# forwarded env is honoured. Without this, root stays at INFO and the
|
|
80
|
+
# DEBUG-level task_log_handler attached by Orchestrator never sees the
|
|
81
|
+
# agent's per-tool-call DEBUG records.
|
|
82
|
+
log_level = "DEBUG" if verbose else settings.log_level
|
|
83
|
+
setup_logging(level=log_level)
|
|
84
|
+
|
|
85
|
+
# Start the host-heartbeat watchdog: if the host process dies
|
|
86
|
+
# ungracefully (SIGKILL, Claude-Code Escape, crash) before it can
|
|
87
|
+
# `docker kill` us, the heartbeat file in output_dir goes stale and
|
|
88
|
+
# we self-exit -- otherwise the container would keep burning LLM
|
|
89
|
+
# budget orphaned. Daemon thread so it doesn't block normal shutdown.
|
|
90
|
+
import os as _os
|
|
91
|
+
import threading
|
|
92
|
+
import time
|
|
93
|
+
|
|
94
|
+
def _watch_host_heartbeat() -> None:
|
|
95
|
+
heartbeat = output_dir / HEARTBEAT_FILENAME
|
|
96
|
+
# Grace period for the host to write the first counter value.
|
|
97
|
+
time.sleep(HEARTBEAT_STALE_SECONDS)
|
|
98
|
+
last_counter = ""
|
|
99
|
+
last_mtime = 0.0
|
|
100
|
+
last_change = time.monotonic()
|
|
101
|
+
while True:
|
|
102
|
+
try:
|
|
103
|
+
current = heartbeat.read_text(encoding="utf-8")
|
|
104
|
+
except (FileNotFoundError, OSError):
|
|
105
|
+
current = ""
|
|
106
|
+
try:
|
|
107
|
+
current_mtime = heartbeat.stat().st_mtime
|
|
108
|
+
except (FileNotFoundError, OSError):
|
|
109
|
+
current_mtime = 0.0
|
|
110
|
+
now = time.monotonic()
|
|
111
|
+
if heartbeat_is_alive(current, last_counter, current_mtime, last_mtime):
|
|
112
|
+
last_counter = current
|
|
113
|
+
last_mtime = current_mtime
|
|
114
|
+
last_change = now
|
|
115
|
+
if now - last_change > HEARTBEAT_STALE_SECONDS:
|
|
116
|
+
logger.error(
|
|
117
|
+
"Host heartbeat stale (>%ss); exiting to reap orphan container.",
|
|
118
|
+
HEARTBEAT_STALE_SECONDS,
|
|
119
|
+
)
|
|
120
|
+
# os._exit skips atexit and IO flushing, so the error line
|
|
121
|
+
# above would routinely be lost -- making a genuine
|
|
122
|
+
# stale-heartbeat suicide indistinguishable from an external
|
|
123
|
+
# SIGKILL in the archived logs. Flush best-effort first;
|
|
124
|
+
# never let a flush failure stop the exit.
|
|
125
|
+
import sys as _sys
|
|
126
|
+
|
|
127
|
+
for _handler in logging.getLogger().handlers:
|
|
128
|
+
with contextlib.suppress(Exception):
|
|
129
|
+
_handler.flush()
|
|
130
|
+
with contextlib.suppress(Exception):
|
|
131
|
+
_sys.stdout.flush()
|
|
132
|
+
with contextlib.suppress(Exception):
|
|
133
|
+
_sys.stderr.flush()
|
|
134
|
+
_os._exit(137)
|
|
135
|
+
time.sleep(HEARTBEAT_STALE_SECONDS / 4)
|
|
136
|
+
|
|
137
|
+
threading.Thread(target=_watch_host_heartbeat, daemon=True).start()
|
|
138
|
+
|
|
139
|
+
task_yaml = input_dir / "task.yaml"
|
|
140
|
+
context_json = input_dir / "context.json"
|
|
141
|
+
if not task_yaml.exists():
|
|
142
|
+
typer.echo(f"FATAL: missing {task_yaml}", err=True)
|
|
143
|
+
raise typer.Exit(2)
|
|
144
|
+
if not context_json.exists():
|
|
145
|
+
typer.echo(f"FATAL: missing {context_json}", err=True)
|
|
146
|
+
raise typer.Exit(2)
|
|
147
|
+
|
|
148
|
+
context = json.loads(context_json.read_text(encoding="utf-8"))
|
|
149
|
+
variant_id: str = context["variant_id"]
|
|
150
|
+
replicate_index: int = context.get("replicate_index", 0)
|
|
151
|
+
# The host resolves the driver-derived default before dispatch; the container
|
|
152
|
+
# obeys it verbatim. This command only ever runs inside the docker driver, so
|
|
153
|
+
# a missing key falls back to the docker default (DIRECT_WRITE) — a deliberate
|
|
154
|
+
# default, not version back-compat.
|
|
155
|
+
preservation_mode = PreservationMode(context.get("preservation_mode", PreservationMode.DIRECT_WRITE.value))
|
|
156
|
+
# Docker WORKDIR alignment: the host resolves the concrete WORKDIR
|
|
157
|
+
# (config value / "auto" -> `docker inspect` / fallback) and forwards it here.
|
|
158
|
+
# Absent -> None -> standard run_dir/artifacts workspace.
|
|
159
|
+
workspace_dir_raw = context.get("workspace_dir")
|
|
160
|
+
workspace_dir = Path(workspace_dir_raw) if workspace_dir_raw else None
|
|
161
|
+
config_lineage = {k: ConfigLineageEntry.model_validate(v) for k, v in (context.get("config_lineage") or {}).items()}
|
|
162
|
+
# Prefer the host's raw source_yaml so task.json's audit trail matches
|
|
163
|
+
# the in-process driver. Fall back to the staged (post-override) YAML
|
|
164
|
+
# for older host versions that didn't forward it.
|
|
165
|
+
host_source_yaml: str | None = context.get("source_yaml")
|
|
166
|
+
|
|
167
|
+
# Load the post-override spec from the staged YAML. We then point
|
|
168
|
+
# `task_file` at a path *under the symmetric task_dir mount* so the
|
|
169
|
+
# Orchestrator's `task_file.parent` reasoning -- specifically the
|
|
170
|
+
# `TASK_DIR` env exposed to `run_command` criteria -- resolves to the
|
|
171
|
+
# original host task directory rather than `/work/input/`.
|
|
172
|
+
task, source_yaml = load_task(task_yaml)
|
|
173
|
+
if host_source_yaml is not None:
|
|
174
|
+
source_yaml = host_source_yaml
|
|
175
|
+
# The path below is never re-read; it only seeds Orchestrator's TASK_DIR.
|
|
176
|
+
runtime_task_file = task_dir / "task.yaml" if task_dir.is_dir() else task_yaml
|
|
177
|
+
|
|
178
|
+
# Force driver back to tempdir for the actual in-container run.
|
|
179
|
+
# We're already inside the container; another nested docker would be
|
|
180
|
+
# both wrong and impossible (no docker CLI in image).
|
|
181
|
+
if task.sandbox.driver == "docker":
|
|
182
|
+
task = task.model_copy(update={"sandbox": task.sandbox.model_copy(update={"driver": "tempdir"})})
|
|
183
|
+
|
|
184
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
185
|
+
|
|
186
|
+
# Late import: orchestrator pulls in heavy deps (anthropic SDK etc.)
|
|
187
|
+
# that we don't want to load just to print --help.
|
|
188
|
+
from coder_eval.orchestrator import Orchestrator
|
|
189
|
+
|
|
190
|
+
orchestrator = Orchestrator(
|
|
191
|
+
task=task,
|
|
192
|
+
run_dir=output_dir,
|
|
193
|
+
preservation_mode=preservation_mode,
|
|
194
|
+
task_file=runtime_task_file,
|
|
195
|
+
variant_id=variant_id,
|
|
196
|
+
source_yaml=source_yaml,
|
|
197
|
+
config_lineage=config_lineage,
|
|
198
|
+
replicate_index=replicate_index,
|
|
199
|
+
workspace_dir=workspace_dir,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
# Install the stdout-NDJSON stream callback so per-tool-call events
|
|
203
|
+
# reach the host. Late import keeps the streaming module out of the
|
|
204
|
+
# default --help path.
|
|
205
|
+
from coder_eval.streaming.wire import StdoutNDJsonCallback
|
|
206
|
+
|
|
207
|
+
orchestrator.stream_callback = StdoutNDJsonCallback()
|
|
208
|
+
|
|
209
|
+
asyncio.run(orchestrator.run())
|
|
210
|
+
# Orchestrator.run() writes task.json to run_dir (== output_dir). Done.
|
coder_eval/cli/utils.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Shared utility functions for CLI commands."""
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
|
|
5
|
+
from ..config import settings
|
|
6
|
+
from .console import console
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def check_tools() -> None:
|
|
10
|
+
"""Check that required tools are available."""
|
|
11
|
+
console.print("[bold]Checking required tools...[/bold]")
|
|
12
|
+
|
|
13
|
+
tools = {
|
|
14
|
+
"claude": "Claude Code CLI",
|
|
15
|
+
"uv": "UV package manager",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
all_found = True
|
|
19
|
+
for cmd, name in tools.items():
|
|
20
|
+
if shutil.which(cmd):
|
|
21
|
+
console.print(f" [green]✓[/green] {name} ({cmd})")
|
|
22
|
+
else:
|
|
23
|
+
console.print(f" [red]✗[/red] {name} ({cmd}) not found")
|
|
24
|
+
all_found = False
|
|
25
|
+
|
|
26
|
+
if not all_found:
|
|
27
|
+
console.print("[yellow]Warning: Some tools are missing[/yellow]")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def check_api_keys() -> None:
|
|
31
|
+
"""Check that API keys are configured."""
|
|
32
|
+
console.print("\n[bold]Checking API keys...[/bold]")
|
|
33
|
+
|
|
34
|
+
if settings.anthropic_api_key:
|
|
35
|
+
console.print(" [green]✓[/green] ANTHROPIC_API_KEY is set")
|
|
36
|
+
else:
|
|
37
|
+
console.print(" [yellow]⚠[/yellow] ANTHROPIC_API_KEY not set")
|
coder_eval/config.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Configuration management using pydantic-settings."""
|
|
2
|
+
|
|
3
|
+
# by-design model-hub ↔ config type-level cycle; runtime imports are lazy per CE017
|
|
4
|
+
# pyright: reportImportCycles=false
|
|
5
|
+
|
|
6
|
+
import base64
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from dotenv import dotenv_values, load_dotenv
|
|
12
|
+
from pydantic import AliasChoices, Field
|
|
13
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
14
|
+
|
|
15
|
+
from coder_eval.models import AgentKind, ApiBackend
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# Application Insights connection string baked into the application so a fresh
|
|
19
|
+
# install reports usage telemetry to the shared coder-eval resource with no
|
|
20
|
+
# configuration. An explicitly-set connection string (env or .env, via any of the
|
|
21
|
+
# field's aliases below) takes precedence — pydantic-settings always prefers an
|
|
22
|
+
# env value over a field default.
|
|
23
|
+
#
|
|
24
|
+
# This is an INGESTION-ONLY connection string (InstrumentationKey + IngestionEndpoint):
|
|
25
|
+
# it can only WRITE telemetry to the resource, never read/query/manage it — the same
|
|
26
|
+
# class of value embedded in every distributed telemetry client (VS Code, Azure CLI,
|
|
27
|
+
# gh, the UiPath CLI). Approved by security for embedding. It is base64-wrapped ONLY
|
|
28
|
+
# to avoid tripping naive secret scanners / push-protection and to mark it as an
|
|
29
|
+
# intentional, reviewed default — NOT for secrecy (base64 is trivially reversible).
|
|
30
|
+
# Residual risk is telemetry spoofing / ingestion-cost abuse, bounded by the resource
|
|
31
|
+
# being dedicated to coder-eval usage telemetry.
|
|
32
|
+
_DEFAULT_TELEMETRY_CONNECTION_STRING = base64.b64decode(
|
|
33
|
+
"SW5zdHJ1bWVudGF0aW9uS2V5PTgxZDBkOGI1LTg1ZjktNDMxNS1iYjJlLTg4ODg0Y2ZkYTVhNztJbmdlc3Rpb25FbmRwb2ludD1odHRwczovL3dlc3R1czItMi5pbi5hcHBsaWNhdGlvbmluc2lnaHRzLmF6dXJlLmNvbS87TGl2ZUVuZHBvaW50PWh0dHBzOi8vd2VzdHVzMi5saXZlZGlhZ25vc3RpY3MubW9uaXRvci5henVyZS5jb20vO0FwcGxpY2F0aW9uSWQ9MDRjN2U3ZjItYjg0OC00ZjhlLTkxNzMtZjI3NmE1YTAwMzk0"
|
|
34
|
+
).decode("utf-8")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Load .env file with override so .env values always win over shell environment
|
|
38
|
+
load_dotenv(override=True)
|
|
39
|
+
|
|
40
|
+
# For certain keys, we want .env values to take precedence over shell environment
|
|
41
|
+
# because the shell may have outdated/different credentials
|
|
42
|
+
env_values = dotenv_values(".env")
|
|
43
|
+
for key in [
|
|
44
|
+
"ANTHROPIC_API_KEY",
|
|
45
|
+
]:
|
|
46
|
+
value = env_values.get(key)
|
|
47
|
+
if value:
|
|
48
|
+
os.environ[key] = value
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# Removed layer-5 `.env` knobs → their `-D` override paths. pydantic-settings
|
|
52
|
+
# silently ignores unknown env vars, so without an explicit guard a stale knob
|
|
53
|
+
# would silently stop having any effect — fail loud with a migration hint instead.
|
|
54
|
+
_REMOVED_DEFAULT_KNOBS = {
|
|
55
|
+
"DEFAULT_AGENT_MODEL": "agent.model",
|
|
56
|
+
"DEFAULT_PERMISSION_MODE": "agent.permission_mode",
|
|
57
|
+
"DEFAULT_MAX_TURNS": "run_limits.max_turns",
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _reject_removed_default_knobs() -> None:
|
|
62
|
+
"""Fail loud on stale removed DEFAULT_* env knobs (see _REMOVED_DEFAULT_KNOBS).
|
|
63
|
+
|
|
64
|
+
An os.environ-only check suffices: load_dotenv(override=True) at module
|
|
65
|
+
import folds the .env file into os.environ before Settings is constructed
|
|
66
|
+
(if that load_dotenv were ever removed, this guard would silently narrow to
|
|
67
|
+
shell-env-only).
|
|
68
|
+
|
|
69
|
+
Called from Settings.__init__ BEFORE pydantic validation so the plain
|
|
70
|
+
ValueError propagates as-is — a pydantic ValidationError would echo the
|
|
71
|
+
full input dict (including API keys) into the error message.
|
|
72
|
+
"""
|
|
73
|
+
stale = [name for name in _REMOVED_DEFAULT_KNOBS if os.environ.get(name)]
|
|
74
|
+
if stale:
|
|
75
|
+
hints = " ".join(
|
|
76
|
+
f"{name} was removed — set the baseline in experiments/default.yaml"
|
|
77
|
+
+ f" ({_REMOVED_DEFAULT_KNOBS[name]}) or override per-run with -D {_REMOVED_DEFAULT_KNOBS[name]}=…"
|
|
78
|
+
+ (" / --model." if name == "DEFAULT_AGENT_MODEL" else ".")
|
|
79
|
+
for name in stale
|
|
80
|
+
)
|
|
81
|
+
raise ValueError(f"{hints} Remove the variable(s) from your .env / shell environment.")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class Settings(BaseSettings):
|
|
85
|
+
"""Application settings loaded from environment variables."""
|
|
86
|
+
|
|
87
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
88
|
+
_reject_removed_default_knobs()
|
|
89
|
+
super().__init__(*args, **kwargs)
|
|
90
|
+
|
|
91
|
+
# API Keys (for Claude Code agent only)
|
|
92
|
+
anthropic_api_key: str | None = None
|
|
93
|
+
|
|
94
|
+
# Paths
|
|
95
|
+
runs_dir: Path = Path("runs") # Base directory for timestamped runs
|
|
96
|
+
|
|
97
|
+
# API Backend routing
|
|
98
|
+
api_backend: ApiBackend = ApiBackend.DIRECT
|
|
99
|
+
|
|
100
|
+
# AWS Bedrock settings (used when api_backend == "bedrock")
|
|
101
|
+
aws_bearer_token_bedrock: str | None = None
|
|
102
|
+
aws_region: str | None = None
|
|
103
|
+
bedrock_model: str | None = None # Cross-region model ID
|
|
104
|
+
bedrock_small_model: str | None = None # Cross-region small model ID
|
|
105
|
+
|
|
106
|
+
# Codex settings (CodexAgent). CODEX_MODEL is the fallback model/deployment
|
|
107
|
+
# used when a task doesn't pin agent.model; CODEX_BASE_URL routes to a custom
|
|
108
|
+
# OpenAI-/responses-compatible endpoint (incl. Azure OpenAI). For Azure also
|
|
109
|
+
# set CODEX_API_VERSION (the required ``api-version`` query param) and use the
|
|
110
|
+
# deployment name as the model. CODEX_BASE_URL / CODEX_API_VERSION /
|
|
111
|
+
# CODEX_API_KEY are read directly via os.getenv in the agent, not mirrored here.
|
|
112
|
+
codex_model: str | None = None
|
|
113
|
+
|
|
114
|
+
# Antigravity settings (AntigravityAgent — Google's Gemini coding harness).
|
|
115
|
+
# GEMINI_API_KEY authenticates the local harness (read from .env here so the
|
|
116
|
+
# export loop below re-publishes it to os.environ, where the google-antigravity
|
|
117
|
+
# SDK looks for it). ANTIGRAVITY_MODEL is the fallback Gemini model used when a
|
|
118
|
+
# task doesn't pin agent.model.
|
|
119
|
+
gemini_api_key: str | None = None
|
|
120
|
+
antigravity_model: str | None = None
|
|
121
|
+
|
|
122
|
+
# Logging
|
|
123
|
+
log_level: str = "INFO" # Default log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
|
124
|
+
log_to_file: bool = False # Whether to enable file logging
|
|
125
|
+
|
|
126
|
+
# Usage telemetry (OpenTelemetry → Azure Application Insights customEvents).
|
|
127
|
+
# On by default via the baked-in connection string; see coder_eval/telemetry.py.
|
|
128
|
+
# telemetry_enabled (TELEMETRY_ENABLED) is the single canonical disable gate.
|
|
129
|
+
telemetry_enabled: bool = True
|
|
130
|
+
# Defaults to the embedded coder-eval resource; any set value (env or .env, via
|
|
131
|
+
# the aliases below) overrides it — pydantic-settings prefers env over default.
|
|
132
|
+
telemetry_connection_string: str | None = Field(
|
|
133
|
+
default=_DEFAULT_TELEMETRY_CONNECTION_STRING,
|
|
134
|
+
validation_alias=AliasChoices(
|
|
135
|
+
"telemetry_connection_string",
|
|
136
|
+
"applicationinsights_connection_string",
|
|
137
|
+
"uipath_ai_connection_string",
|
|
138
|
+
),
|
|
139
|
+
)
|
|
140
|
+
# Caller-settable origin stamp (TELEMETRY_SOURCE), emitted as the `Source`
|
|
141
|
+
# dimension on every event. Lets downstream pipelines tag themselves (e.g.
|
|
142
|
+
# `nightly-vm` / `skill-eval`) so internal runs are distinguishable from
|
|
143
|
+
# anonymous local ones — `IsCI` alone can't, and the framework's own CI is
|
|
144
|
+
# muted. Defaults to "coder-eval" for a plain local install.
|
|
145
|
+
telemetry_source: str = "coder-eval"
|
|
146
|
+
|
|
147
|
+
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="ignore")
|
|
148
|
+
|
|
149
|
+
def _validate_bedrock_settings(self) -> None:
|
|
150
|
+
"""Validate that required AWS Bedrock settings are present.
|
|
151
|
+
|
|
152
|
+
Raises:
|
|
153
|
+
ValueError: If required Bedrock settings are missing
|
|
154
|
+
"""
|
|
155
|
+
missing = []
|
|
156
|
+
if not self.aws_bearer_token_bedrock:
|
|
157
|
+
missing.append("AWS_BEARER_TOKEN_BEDROCK")
|
|
158
|
+
if not self.aws_region:
|
|
159
|
+
missing.append("AWS_REGION")
|
|
160
|
+
# BEDROCK_MODEL is the route-level model source. Without it, an
|
|
161
|
+
# invocation that doesn't override via --model / task.agent.model
|
|
162
|
+
# would send model=None to the SDK and Bedrock would return an
|
|
163
|
+
# opaque 400. Fail fast at startup with a clear error.
|
|
164
|
+
if not self.bedrock_model:
|
|
165
|
+
missing.append("BEDROCK_MODEL")
|
|
166
|
+
if missing:
|
|
167
|
+
raise ValueError(
|
|
168
|
+
f"Bedrock routing is enabled but missing required settings: {', '.join(missing)}."
|
|
169
|
+
+ " Please set them in your .env file."
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
def validate_api_keys(self, agent_type: str) -> None:
|
|
173
|
+
"""Validate that required API keys are present.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
agent_type: The type of agent being used
|
|
177
|
+
|
|
178
|
+
Raises:
|
|
179
|
+
ValueError: If required API key is missing
|
|
180
|
+
"""
|
|
181
|
+
# The no-op agent (agent: {type: none}) makes no model API call, so it
|
|
182
|
+
# needs no credentials — not even the backend (Bedrock) settings.
|
|
183
|
+
if agent_type == AgentKind.NONE.value:
|
|
184
|
+
return
|
|
185
|
+
|
|
186
|
+
if self.api_backend == ApiBackend.BEDROCK:
|
|
187
|
+
self._validate_bedrock_settings()
|
|
188
|
+
|
|
189
|
+
# Claude Code agent can use either:
|
|
190
|
+
# 1. ANTHROPIC_API_KEY environment variable
|
|
191
|
+
# 2. Cached CLI authentication from 'claude-code login' (subscription account)
|
|
192
|
+
# We don't validate the API key here because the SDK handles auth and fails clearly if missing.
|
|
193
|
+
if agent_type == AgentKind.CLAUDE_CODE.value:
|
|
194
|
+
return
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# Global settings instance
|
|
198
|
+
settings = Settings()
|
|
199
|
+
|
|
200
|
+
# Export settings to environment variables for external libraries (the Anthropic SDK,
|
|
201
|
+
# boto3/Bedrock) that use os.getenv() instead of reading from the Settings object.
|
|
202
|
+
# Only export non-None values and convert non-string types to strings.
|
|
203
|
+
for key, value in settings.model_dump().items():
|
|
204
|
+
if value is not None:
|
|
205
|
+
env_key = key.upper()
|
|
206
|
+
# Convert Path objects and other types to strings
|
|
207
|
+
if isinstance(value, Path):
|
|
208
|
+
os.environ[env_key] = str(value)
|
|
209
|
+
elif isinstance(value, bool):
|
|
210
|
+
os.environ[env_key] = str(value).lower()
|
|
211
|
+
else:
|
|
212
|
+
os.environ[env_key] = str(value)
|