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,206 @@
|
|
|
1
|
+
"""Layer-5 ``-D path=value`` / ``--set`` task-config override engine.
|
|
2
|
+
|
|
3
|
+
The thin layer-5 wrapper over the generic resolver (:mod:`config_merge`). It
|
|
4
|
+
|
|
5
|
+
1. parses a scalar value (YAML-safe, with the YAML-1.1 truthy-alias guard),
|
|
6
|
+
2. groups dotted ``path=value`` overrides into one nested ``-D`` patch per
|
|
7
|
+
root (``agent`` / ``run_limits`` / ``sandbox``),
|
|
8
|
+
3. calls :func:`config_merge.resolve_root` with a lineage-silent value seed
|
|
9
|
+
(the already-resolved model) + the ``-D`` patch layer, so a field merges
|
|
10
|
+
under exactly the same strategy at layer 5 as at the variant layer.
|
|
11
|
+
|
|
12
|
+
Path *validation* (did-you-mean on typos) lives in
|
|
13
|
+
:func:`config_merge.validate_paths`; value validation happens in the root
|
|
14
|
+
constructors during reconstruction.
|
|
15
|
+
|
|
16
|
+
Deliberately CLI-free (no ``typer`` import — lint rule CE004). The CLI boundary
|
|
17
|
+
wraps :class:`OverrideError` / :class:`config_merge.MergeError` into
|
|
18
|
+
``typer.BadParameter``.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from collections.abc import Mapping
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
import yaml
|
|
27
|
+
from pydantic import ValidationError
|
|
28
|
+
|
|
29
|
+
from ..models import AgentKind, ConfigLineageEntry, TaskDefinition
|
|
30
|
+
from .config_merge import ALLOWED_OVERRIDE_ROOTS, Layer, MergeError, RootName, resolve_root
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# YAML 1.1 truthy aliases PyYAML coerces to bool. We keep them as strings so
|
|
34
|
+
# e.g. ``-D agent.model=on`` doesn't silently become ``True``. This is the single
|
|
35
|
+
# scalar-parsing implementation for all ``-D``/``--set`` values.
|
|
36
|
+
_YAML_TRUTHY_ALIASES = frozenset({"y", "n", "yes", "no", "on", "off"})
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class OverrideError(ValueError):
|
|
40
|
+
"""Raised for malformed or schema-invalid ``-D`` overrides."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def parse_scalar(value: str) -> Any:
|
|
44
|
+
"""YAML-safe-load a scalar, keeping YAML-1.1 truthy aliases as strings.
|
|
45
|
+
|
|
46
|
+
``on``/``off``/``yes``/``no``/``y``/``n`` (case-insensitive) stay strings to
|
|
47
|
+
avoid the foot-gun where a string-valued field colliding with a YAML keyword
|
|
48
|
+
is silently turned into a bool. Explicit ``true``/``false`` (YAML 1.2
|
|
49
|
+
canonical) coerce normally. Raises :class:`OverrideError` on invalid YAML.
|
|
50
|
+
"""
|
|
51
|
+
try:
|
|
52
|
+
parsed = yaml.safe_load(value)
|
|
53
|
+
except yaml.YAMLError as e:
|
|
54
|
+
raise OverrideError(f"value is not valid YAML: {e}") from e
|
|
55
|
+
if isinstance(parsed, bool) and value.strip().lower() in _YAML_TRUTHY_ALIASES:
|
|
56
|
+
parsed = value
|
|
57
|
+
return parsed
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def parse_override(raw: str) -> tuple[str, Any]:
|
|
61
|
+
"""Split ``path=value`` on the FIRST ``=`` into ``(path, parsed_value)``.
|
|
62
|
+
|
|
63
|
+
Raises :class:`OverrideError` when ``=`` is absent or the path is empty.
|
|
64
|
+
The value is coerced via :func:`parse_scalar`.
|
|
65
|
+
"""
|
|
66
|
+
if "=" not in raw:
|
|
67
|
+
raise OverrideError(f"override must be PATH=VALUE, got: {raw!r}")
|
|
68
|
+
path, _, value = raw.partition("=")
|
|
69
|
+
path = path.strip()
|
|
70
|
+
if not path:
|
|
71
|
+
raise OverrideError(f"override path cannot be empty: {raw!r}")
|
|
72
|
+
return path, parse_scalar(value)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _assign_nested(patch: dict[str, Any], segments: list[str], value: Any) -> None:
|
|
76
|
+
"""Set ``patch[seg0][seg1]... = value``, creating intermediate dicts.
|
|
77
|
+
|
|
78
|
+
Merges multiple ``-D`` entries that share a prefix (e.g.
|
|
79
|
+
``agent.sdk_options.effort`` and ``agent.sdk_options.max_thinking_tokens``)
|
|
80
|
+
into one nested patch.
|
|
81
|
+
"""
|
|
82
|
+
cursor = patch
|
|
83
|
+
for seg in segments[:-1]:
|
|
84
|
+
nxt = cursor.get(seg)
|
|
85
|
+
if not isinstance(nxt, dict):
|
|
86
|
+
nxt = {}
|
|
87
|
+
cursor[seg] = nxt
|
|
88
|
+
cursor = nxt
|
|
89
|
+
cursor[segments[-1]] = value
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def apply_overrides(
|
|
93
|
+
task: TaskDefinition,
|
|
94
|
+
overrides: Mapping[str, Any],
|
|
95
|
+
*,
|
|
96
|
+
agent_type: str | None = None,
|
|
97
|
+
lineage: dict[str, ConfigLineageEntry] | None = None,
|
|
98
|
+
) -> None:
|
|
99
|
+
"""Apply layer-5 overrides onto a resolved ``TaskDefinition`` in place.
|
|
100
|
+
|
|
101
|
+
Groups ``overrides`` (dotted ``path=value``) into one nested ``-D`` patch per
|
|
102
|
+
root, then resolves each affected root via :func:`config_merge.resolve_root`
|
|
103
|
+
with a **lineage-silent** value seed (the already-resolved model) so the
|
|
104
|
+
layers-1-4 provenance for fields ``-D`` doesn't touch is preserved. Each
|
|
105
|
+
``-D`` field obeys its declared merge strategy (append fields append, etc.) —
|
|
106
|
+
the same rule the variant layer uses.
|
|
107
|
+
|
|
108
|
+
``agent_type`` (from ``--type``) is injected into the agent patch's ``type``
|
|
109
|
+
via ``setdefault`` (an explicit ``-D agent.type`` wins) and records ``"--type"``
|
|
110
|
+
as its ``source_detail``; all other paths auto-derive ``"-D <path>"``.
|
|
111
|
+
Unknown roots/keys raise
|
|
112
|
+
:class:`OverrideError` (the latter re-raised from
|
|
113
|
+
:class:`config_merge.MergeError`).
|
|
114
|
+
"""
|
|
115
|
+
agent_patch: dict[str, Any] = {}
|
|
116
|
+
rl_patch: dict[str, Any] = {}
|
|
117
|
+
sandbox_patch: dict[str, Any] = {}
|
|
118
|
+
by_root = {"agent": agent_patch, "run_limits": rl_patch, "sandbox": sandbox_patch}
|
|
119
|
+
|
|
120
|
+
for path, value in overrides.items():
|
|
121
|
+
root, _, rest = path.partition(".")
|
|
122
|
+
if root not in by_root:
|
|
123
|
+
raise OverrideError(f"unknown override root {root!r}; allowed roots: {', '.join(ALLOWED_OVERRIDE_ROOTS)}")
|
|
124
|
+
_assign_nested(by_root[root], rest.split("."), value)
|
|
125
|
+
|
|
126
|
+
agent_detail: Mapping[str, str] | None = None
|
|
127
|
+
if agent_type is not None and "type" not in agent_patch:
|
|
128
|
+
# `--type` injection (no explicit `-D agent.type`): record its provenance
|
|
129
|
+
# as "--type". An explicit `-D agent.type` keeps its own "-D" detail.
|
|
130
|
+
agent_patch["type"] = agent_type
|
|
131
|
+
agent_detail = {"agent.type": "--type"}
|
|
132
|
+
|
|
133
|
+
if agent_patch:
|
|
134
|
+
assert task.agent is not None, f"Task '{task.task_id}' has no agent config"
|
|
135
|
+
# Preserve the friendly "sdk_options only for claude-code" message before
|
|
136
|
+
# reconstruction, keyed on the type the agent is *becoming*.
|
|
137
|
+
if "sdk_options" in agent_patch:
|
|
138
|
+
becoming = agent_patch.get("type", task.agent.type)
|
|
139
|
+
type_value = becoming.value if isinstance(becoming, AgentKind) else becoming
|
|
140
|
+
if type_value != AgentKind.CLAUDE_CODE.value:
|
|
141
|
+
where = "no agent type is set" if type_value is None else f"agent type {type_value}"
|
|
142
|
+
raise OverrideError(
|
|
143
|
+
f"sdk_options cannot be used with {where}. This option is only supported for claude-code agents."
|
|
144
|
+
)
|
|
145
|
+
# Seed with only the explicitly-set fields (exclude_unset) so switching the
|
|
146
|
+
# agent subclass via --type doesn't drag subclass-only defaults into a model
|
|
147
|
+
# that forbids them.
|
|
148
|
+
task.agent = _resolve(
|
|
149
|
+
"agent", task.agent.model_dump(exclude_unset=True), agent_patch, detail=agent_detail, lineage=lineage
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
if rl_patch:
|
|
153
|
+
seed = task.run_limits.model_dump(exclude_unset=True) if task.run_limits else {}
|
|
154
|
+
task.run_limits = _resolve("run_limits", seed, rl_patch, detail=None, lineage=lineage)
|
|
155
|
+
|
|
156
|
+
if sandbox_patch:
|
|
157
|
+
# Full dump: the resolved sandbox already folded layers 1-4 (incl. the
|
|
158
|
+
# append-semantics env_passthrough_extra/template_sources merges), so the
|
|
159
|
+
# full dump is the authoritative value base to patch onto.
|
|
160
|
+
resolved = _resolve("sandbox", task.sandbox.model_dump(), sandbox_patch, detail=None, lineage=lineage)
|
|
161
|
+
assert resolved is not None # sandbox seed is always non-empty
|
|
162
|
+
task.sandbox = resolved
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _resolve(
|
|
166
|
+
root: RootName,
|
|
167
|
+
seed_patch: dict[str, Any],
|
|
168
|
+
cli_patch: dict[str, Any],
|
|
169
|
+
*,
|
|
170
|
+
detail: Mapping[str, str] | None,
|
|
171
|
+
lineage: dict[str, ConfigLineageEntry] | None,
|
|
172
|
+
) -> Any:
|
|
173
|
+
"""resolve_root over a lineage-silent seed + the recording ``-D`` layer.
|
|
174
|
+
|
|
175
|
+
Re-raises both failure modes as :class:`OverrideError` so the core stays
|
|
176
|
+
typer-free and both read alike at the CLI: a :class:`config_merge.MergeError`
|
|
177
|
+
(unknown ``-D`` key, with a did-you-mean) and a Pydantic
|
|
178
|
+
:class:`ValidationError` (a bad ``-D`` *value*, condensed to a path-prefixed
|
|
179
|
+
line instead of the raw multi-line dump + ``errors.pydantic.dev`` URL).
|
|
180
|
+
"""
|
|
181
|
+
layers = [
|
|
182
|
+
Layer(source="cli", patch=seed_patch, record_lineage=False),
|
|
183
|
+
Layer(source="cli", patch=cli_patch, detail=detail),
|
|
184
|
+
]
|
|
185
|
+
try:
|
|
186
|
+
return resolve_root(root, layers, lineage=lineage)
|
|
187
|
+
except MergeError as e:
|
|
188
|
+
raise OverrideError(str(e)) from e
|
|
189
|
+
except ValidationError as e:
|
|
190
|
+
raise OverrideError(_format_validation_error(root, e)) from e
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _format_validation_error(root: RootName, exc: ValidationError) -> str:
|
|
194
|
+
"""Condense a Pydantic ``ValidationError`` into one path-prefixed ``-D`` line
|
|
195
|
+
per error, dropping the verbose type tags and ``errors.pydantic.dev`` URL so a
|
|
196
|
+
bad value reads like the (already-clean) unknown-key errors."""
|
|
197
|
+
parts: list[str] = []
|
|
198
|
+
for err in exc.errors():
|
|
199
|
+
leaf = ".".join(str(p) for p in err["loc"])
|
|
200
|
+
path = f"{root}.{leaf}" if leaf else root
|
|
201
|
+
msg = err.get("msg", "invalid value")
|
|
202
|
+
if "input" in err:
|
|
203
|
+
parts.append(f"-D {path}: {msg} (got {err['input']!r})")
|
|
204
|
+
else:
|
|
205
|
+
parts.append(f"-D {path}: {msg}")
|
|
206
|
+
return "; ".join(parts) if parts else f"invalid -D override for {root!r}: {exc}"
|
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
"""Task definition loading and validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import random
|
|
8
|
+
import re
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import yaml
|
|
13
|
+
|
|
14
|
+
from ..models import (
|
|
15
|
+
AgentConfig,
|
|
16
|
+
BaseAgentConfig,
|
|
17
|
+
Dataset,
|
|
18
|
+
ExperimentVariant,
|
|
19
|
+
TaskDefinition,
|
|
20
|
+
TemplateDirSource,
|
|
21
|
+
TemplateSource,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# Fixed seed for the CLI --sample (max_rows) uniform draw: a smoke sample should
|
|
26
|
+
# be reproducible run-to-run, just not first-path-biased like a raw slice.
|
|
27
|
+
_SMOKE_SAMPLE_SEED = 0
|
|
28
|
+
|
|
29
|
+
_ROW_VAR_PATTERN = re.compile(r"\$\{row\.([A-Za-z_][A-Za-z0-9_]*)\}")
|
|
30
|
+
_ROW_ID_PATTERN = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_.\-]*$")
|
|
31
|
+
_ENV_VAR_PATTERN = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def load_task(task_file: Path) -> tuple[TaskDefinition, str]:
|
|
35
|
+
"""Load a task definition from a YAML file.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
task_file: Path to the task YAML file
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
Tuple of (parsed TaskDefinition, raw YAML text)
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
FileNotFoundError: If task file doesn't exist
|
|
45
|
+
ValueError: If task file is invalid
|
|
46
|
+
"""
|
|
47
|
+
if not task_file.exists():
|
|
48
|
+
raise FileNotFoundError(f"Task file not found: {task_file}")
|
|
49
|
+
|
|
50
|
+
if task_file.is_dir():
|
|
51
|
+
msg = (
|
|
52
|
+
f"Expected a YAML task file but got a directory: {task_file}\n"
|
|
53
|
+
f"Hint: use a glob pattern like '{task_file}/*.yaml' to select task files."
|
|
54
|
+
)
|
|
55
|
+
raise ValueError(msg)
|
|
56
|
+
|
|
57
|
+
raw_yaml = task_file.read_text(encoding="utf-8")
|
|
58
|
+
task_data = yaml.safe_load(raw_yaml)
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
task = TaskDefinition(**task_data)
|
|
62
|
+
# Resolve relative template paths
|
|
63
|
+
task = resolve_template_paths(task, task_file.parent)
|
|
64
|
+
task = resolve_initial_prompt_file(task, task_file.parent)
|
|
65
|
+
task = resolve_system_prompt_files(task, task_file.parent)
|
|
66
|
+
task = resolve_dockerfile_path(task, task_file.parent)
|
|
67
|
+
return task, raw_yaml
|
|
68
|
+
except Exception as e:
|
|
69
|
+
raise ValueError(f"Invalid task definition: {e}") from e
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def resolve_template_source_paths(sources: list[TemplateSource], base_dir: Path) -> None:
|
|
73
|
+
"""Resolve TemplateDirSource paths to absolute, in place.
|
|
74
|
+
|
|
75
|
+
Expands $VAR / ${VAR} environment variables, then normalizes the path:
|
|
76
|
+
relative paths are resolved against ``base_dir``; absolute paths are
|
|
77
|
+
used as-is (but still go through ``Path(...)`` for string normalization).
|
|
78
|
+
|
|
79
|
+
Undefined env variables raise ``ValueError`` — a template directory is a
|
|
80
|
+
load-bearing config field and an unresolved variable would otherwise
|
|
81
|
+
surface as a cryptic "Template directory not found" error at sandbox
|
|
82
|
+
setup, far from the actual configuration mistake.
|
|
83
|
+
|
|
84
|
+
Scope: only environment variables (``$VAR`` / ``${VAR}``) are expanded
|
|
85
|
+
here. Dataset row substitution (``${row.field}`` in ``expand_dataset``)
|
|
86
|
+
runs over ``initial_prompt`` and ``success_criteria`` only — it does
|
|
87
|
+
NOT touch ``sandbox.template_sources``. The two regexes are disjoint
|
|
88
|
+
(env requires ``[A-Za-z_][A-Za-z0-9_]*``, row-var requires the dot)
|
|
89
|
+
but a ``${row.X}`` left inside a template path will not be substituted
|
|
90
|
+
and will fail at sandbox setup.
|
|
91
|
+
|
|
92
|
+
Skips non-TemplateDirSource entries.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
sources: List of template sources (TemplateDirSource, RepoSource, etc.)
|
|
96
|
+
base_dir: Base directory for resolving relative paths.
|
|
97
|
+
|
|
98
|
+
Raises:
|
|
99
|
+
ValueError: If a ``TemplateDirSource.path`` references an undefined
|
|
100
|
+
environment variable.
|
|
101
|
+
"""
|
|
102
|
+
for source in sources:
|
|
103
|
+
if isinstance(source, TemplateDirSource):
|
|
104
|
+
raw = source.path
|
|
105
|
+
undefined: list[str] = []
|
|
106
|
+
for match in _ENV_VAR_PATTERN.finditer(raw):
|
|
107
|
+
var_name = match.group(1) or match.group(2)
|
|
108
|
+
if var_name not in os.environ:
|
|
109
|
+
undefined.append(var_name)
|
|
110
|
+
if undefined:
|
|
111
|
+
names = ", ".join(f"${v}" for v in undefined)
|
|
112
|
+
msg = (
|
|
113
|
+
f"Template path {raw!r} references undefined environment variable(s): {names}. "
|
|
114
|
+
f"Set them before loading the task (e.g. in .env) so the template directory can be resolved."
|
|
115
|
+
)
|
|
116
|
+
raise ValueError(msg)
|
|
117
|
+
expanded = os.path.expandvars(raw)
|
|
118
|
+
template_path = Path(expanded)
|
|
119
|
+
if template_path.is_absolute():
|
|
120
|
+
source.path = str(template_path)
|
|
121
|
+
else:
|
|
122
|
+
source.path = str((base_dir / template_path).resolve())
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def resolve_template_paths(task: TaskDefinition, base_dir: Path) -> TaskDefinition:
|
|
126
|
+
"""Resolve relative template paths to absolute paths.
|
|
127
|
+
|
|
128
|
+
Mutates TemplateDirSource.path in place. Other source types don't need resolution.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
task: Task definition with possibly relative paths
|
|
132
|
+
base_dir: Directory containing the task YAML file
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
Task with resolved absolute paths (modified in place)
|
|
136
|
+
"""
|
|
137
|
+
if task.sandbox.template_sources:
|
|
138
|
+
resolve_template_source_paths(task.sandbox.template_sources, base_dir)
|
|
139
|
+
|
|
140
|
+
return task
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def resolve_dockerfile_path(task: TaskDefinition, base_dir: Path) -> TaskDefinition:
|
|
144
|
+
"""Resolve ``sandbox.docker.dockerfile_path`` to an absolute path, in place.
|
|
145
|
+
|
|
146
|
+
When set, ``dockerfile_path`` is interpreted relative to the task YAML's
|
|
147
|
+
directory (``base_dir``), with ``$VAR`` / ``${VAR}`` environment variables
|
|
148
|
+
expanded first (mirroring :func:`resolve_template_source_paths`). The
|
|
149
|
+
resolved file must exist -- a missing Dockerfile is a configuration error
|
|
150
|
+
surfaced at load time rather than as an opaque ``docker build`` failure.
|
|
151
|
+
|
|
152
|
+
No-op when ``dockerfile_path`` is unset. Resolution runs regardless of the
|
|
153
|
+
configured ``driver`` so the absolute path stays stable even if a later
|
|
154
|
+
layer flips the driver to ``docker``.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
task: Task definition possibly carrying a relative ``dockerfile_path``.
|
|
158
|
+
base_dir: Directory containing the task YAML file.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
The same task with an absolute ``dockerfile_path`` (modified in place).
|
|
162
|
+
|
|
163
|
+
Raises:
|
|
164
|
+
FileNotFoundError: If the resolved Dockerfile does not exist.
|
|
165
|
+
"""
|
|
166
|
+
docker_cfg = task.sandbox.docker
|
|
167
|
+
raw = docker_cfg.dockerfile_path
|
|
168
|
+
if raw is None:
|
|
169
|
+
return task
|
|
170
|
+
dockerfile = Path(os.path.expandvars(raw))
|
|
171
|
+
if not dockerfile.is_absolute():
|
|
172
|
+
dockerfile = (base_dir / dockerfile).resolve()
|
|
173
|
+
if not dockerfile.is_file():
|
|
174
|
+
raise FileNotFoundError(f"Dockerfile not found: {dockerfile}")
|
|
175
|
+
docker_cfg.dockerfile_path = str(dockerfile)
|
|
176
|
+
return task
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def resolve_initial_prompt_file(task: TaskDefinition, base_dir: Path) -> TaskDefinition:
|
|
180
|
+
"""Resolve initial_prompt_file to inline initial_prompt.
|
|
181
|
+
|
|
182
|
+
In simulation mode, both ``initial_prompt`` and ``initial_prompt_file`` may
|
|
183
|
+
be absent — the simulator generates the opening user utterance itself.
|
|
184
|
+
"""
|
|
185
|
+
if task.initial_prompt_file is not None:
|
|
186
|
+
prompt_path = Path(task.initial_prompt_file)
|
|
187
|
+
if not prompt_path.is_absolute():
|
|
188
|
+
prompt_path = (base_dir / prompt_path).resolve()
|
|
189
|
+
if not prompt_path.exists():
|
|
190
|
+
raise FileNotFoundError(f"initial_prompt_file not found: {prompt_path}")
|
|
191
|
+
content = prompt_path.read_text(encoding="utf-8").strip()
|
|
192
|
+
# Clear file field BEFORE setting inline to avoid mutual-exclusivity validator
|
|
193
|
+
task.initial_prompt_file = None
|
|
194
|
+
task.initial_prompt = content
|
|
195
|
+
if task.initial_prompt is None:
|
|
196
|
+
in_simulation = task.simulation is not None and task.simulation.enabled
|
|
197
|
+
if not in_simulation and not task.is_none_agent:
|
|
198
|
+
raise ValueError(
|
|
199
|
+
"Either 'initial_prompt' or 'initial_prompt_file' must be set "
|
|
200
|
+
+ "(unless 'simulation.enabled' is true, in which case the simulator generates the opener, "
|
|
201
|
+
+ "or 'agent.type' is 'none', in which case no agent runs)"
|
|
202
|
+
)
|
|
203
|
+
return task
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def resolve_variant_initial_prompt_file(variant: ExperimentVariant, base_dir: Path) -> None:
|
|
207
|
+
"""Resolve initial_prompt_file on a variant to inline initial_prompt. Mutates in place.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
variant: The experiment variant (may have initial_prompt_file set).
|
|
211
|
+
base_dir: Directory to resolve relative paths against (experiment YAML dir).
|
|
212
|
+
|
|
213
|
+
Raises:
|
|
214
|
+
FileNotFoundError: If the file doesn't exist.
|
|
215
|
+
"""
|
|
216
|
+
if variant.initial_prompt_file is None:
|
|
217
|
+
return
|
|
218
|
+
prompt_path = Path(variant.initial_prompt_file)
|
|
219
|
+
if not prompt_path.is_absolute():
|
|
220
|
+
prompt_path = (base_dir / prompt_path).resolve()
|
|
221
|
+
if not prompt_path.exists():
|
|
222
|
+
raise FileNotFoundError(f"variant initial_prompt_file not found: {prompt_path}")
|
|
223
|
+
content = prompt_path.read_text(encoding="utf-8").strip()
|
|
224
|
+
# Clear file field BEFORE setting inline to avoid mutual-exclusivity validator
|
|
225
|
+
variant.initial_prompt_file = None
|
|
226
|
+
variant.initial_prompt = content
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def resolve_agent_system_prompt(agent_config: AgentConfig | BaseAgentConfig | None, base_dir: Path) -> None:
|
|
230
|
+
"""Resolve system_prompt_file to inline system_prompt. Mutates in place."""
|
|
231
|
+
if agent_config is None:
|
|
232
|
+
return
|
|
233
|
+
if agent_config.system_prompt_file is not None:
|
|
234
|
+
prompt_path = Path(agent_config.system_prompt_file)
|
|
235
|
+
if not prompt_path.is_absolute():
|
|
236
|
+
prompt_path = (base_dir / prompt_path).resolve()
|
|
237
|
+
if not prompt_path.exists():
|
|
238
|
+
raise FileNotFoundError(f"system_prompt_file not found: {prompt_path}")
|
|
239
|
+
content = prompt_path.read_text(encoding="utf-8").strip()
|
|
240
|
+
# Clear file field BEFORE setting inline to avoid mutual-exclusivity validator
|
|
241
|
+
agent_config.system_prompt_file = None
|
|
242
|
+
agent_config.system_prompt = content
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def resolve_system_prompt_files(task: TaskDefinition, base_dir: Path) -> TaskDefinition:
|
|
246
|
+
"""Resolve system_prompt_file on agent config."""
|
|
247
|
+
if task.agent is not None:
|
|
248
|
+
resolve_agent_system_prompt(task.agent, base_dir)
|
|
249
|
+
return task
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _load_jsonl(path: Path) -> list[dict[str, Any]]:
|
|
253
|
+
"""Read a JSONL file into a list of dicts."""
|
|
254
|
+
if not path.exists():
|
|
255
|
+
raise FileNotFoundError(f"Dataset file not found: {path}")
|
|
256
|
+
rows: list[dict[str, Any]] = []
|
|
257
|
+
with path.open(encoding="utf-8") as f:
|
|
258
|
+
for line_num, raw_line in enumerate(f, start=1):
|
|
259
|
+
line = raw_line.strip()
|
|
260
|
+
if not line:
|
|
261
|
+
continue
|
|
262
|
+
try:
|
|
263
|
+
row = json.loads(line)
|
|
264
|
+
except json.JSONDecodeError as e:
|
|
265
|
+
raise ValueError(f"Dataset {path}: invalid JSON on line {line_num}: {e}") from e
|
|
266
|
+
if not isinstance(row, dict):
|
|
267
|
+
raise ValueError(f"Dataset {path}: row on line {line_num} is not a JSON object: {row!r}")
|
|
268
|
+
rows.append(row)
|
|
269
|
+
return rows
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _resolve_path(p: str, task_file_dir: Path) -> Path:
|
|
273
|
+
path = Path(p)
|
|
274
|
+
return path if path.is_absolute() else (task_file_dir / path).resolve()
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _load_dataset_rows(dataset: Dataset, task_file_dir: Path) -> list[dict[str, Any]]:
|
|
278
|
+
"""Load dataset rows from inline list or one or more JSONL files."""
|
|
279
|
+
if dataset.rows is not None:
|
|
280
|
+
return [dict(r) for r in dataset.rows]
|
|
281
|
+
|
|
282
|
+
assert dataset.paths is not None # guaranteed by Dataset.check_source
|
|
283
|
+
rows: list[dict[str, Any]] = []
|
|
284
|
+
for p in dataset.paths:
|
|
285
|
+
rows.extend(_load_jsonl(_resolve_path(p, task_file_dir)))
|
|
286
|
+
return rows
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _stratified_sample(
|
|
290
|
+
rows: list[dict[str, Any]],
|
|
291
|
+
field: str,
|
|
292
|
+
n: int,
|
|
293
|
+
seed: int | None,
|
|
294
|
+
) -> list[dict[str, Any]]:
|
|
295
|
+
"""Randomly keep up to ``n`` rows per stratum, keyed on ``str(row[field])``.
|
|
296
|
+
|
|
297
|
+
Strata with <= n rows are taken whole. Output preserves first-seen stratum
|
|
298
|
+
order; within a sampled stratum, rows are in their drawn (random) order.
|
|
299
|
+
Rows missing ``field`` fall into the "" stratum (this is where the activation
|
|
300
|
+
dataset's shared negatives — ``expected_skill: ""`` — collect). ``seed=None``
|
|
301
|
+
uses a fresh nondeterministic RNG, so the draw differs every run.
|
|
302
|
+
"""
|
|
303
|
+
rng = random.Random(seed)
|
|
304
|
+
groups: dict[str, list[dict[str, Any]]] = {}
|
|
305
|
+
for row in rows:
|
|
306
|
+
groups.setdefault(str(row.get(field, "")), []).append(row)
|
|
307
|
+
out: list[dict[str, Any]] = []
|
|
308
|
+
for grp in groups.values():
|
|
309
|
+
out.extend(grp if len(grp) <= n else rng.sample(grp, n))
|
|
310
|
+
return out
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _substitute_row_in_str(s: str, row: dict[str, Any]) -> str:
|
|
314
|
+
"""Replace ${row.<field>} occurrences in s with scalar values from row."""
|
|
315
|
+
|
|
316
|
+
def replace(match: re.Match[str]) -> str:
|
|
317
|
+
key = match.group(1)
|
|
318
|
+
if key not in row:
|
|
319
|
+
raise KeyError(f"${{row.{key}}}: key not found (available: {sorted(row.keys())})")
|
|
320
|
+
value = row[key]
|
|
321
|
+
if isinstance(value, dict | list):
|
|
322
|
+
raise TypeError(
|
|
323
|
+
f"${{row.{key}}}: value must be a scalar (str/int/float/bool/None), got {type(value).__name__}"
|
|
324
|
+
)
|
|
325
|
+
return "" if value is None else str(value)
|
|
326
|
+
|
|
327
|
+
return _ROW_VAR_PATTERN.sub(replace, s)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _substitute_row_in_tree(obj: Any, row: dict[str, Any]) -> Any:
|
|
331
|
+
"""Walk a nested dict/list structure and substitute ${row.X} in every string leaf."""
|
|
332
|
+
if isinstance(obj, str):
|
|
333
|
+
return _substitute_row_in_str(obj, row)
|
|
334
|
+
if isinstance(obj, list):
|
|
335
|
+
return [_substitute_row_in_tree(x, row) for x in obj]
|
|
336
|
+
if isinstance(obj, dict):
|
|
337
|
+
return {k: _substitute_row_in_tree(v, row) for k, v in obj.items()}
|
|
338
|
+
return obj
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def expand_dataset(
|
|
342
|
+
task: TaskDefinition,
|
|
343
|
+
task_file_dir: Path,
|
|
344
|
+
max_rows: int | None = None,
|
|
345
|
+
sample_per_stratum: int | None = None,
|
|
346
|
+
) -> list[TaskDefinition]:
|
|
347
|
+
"""Fan out a task with ``dataset:`` into one TaskDefinition per row.
|
|
348
|
+
|
|
349
|
+
Tasks without ``dataset:`` pass through unchanged as ``[task]``.
|
|
350
|
+
|
|
351
|
+
Each expanded task:
|
|
352
|
+
- has task_id rewritten to ``"<original_task_id>/<row_id>"``
|
|
353
|
+
- has ``dataset`` cleared (prevents re-expansion downstream)
|
|
354
|
+
- has ``${row.<field>}`` substituted in ``initial_prompt`` and in all
|
|
355
|
+
string leaves of ``success_criteria`` entries
|
|
356
|
+
|
|
357
|
+
Row ids are validated against a safe pattern so they're filesystem-safe
|
|
358
|
+
when used as directory names under the run_dir.
|
|
359
|
+
|
|
360
|
+
Args:
|
|
361
|
+
task: Task that may carry a dataset.
|
|
362
|
+
task_file_dir: Directory of the source task YAML (for resolving dataset.paths).
|
|
363
|
+
max_rows: Optional CLI cap on rows used (for cheap smoke runs). A
|
|
364
|
+
fixed-seed uniform-random N-row sample over the whole dataset
|
|
365
|
+
(reproducible, but unbiased across ``dataset.paths`` — unlike a raw
|
|
366
|
+
slice). When provided, overrides both ``sample_per_stratum`` args.
|
|
367
|
+
Absent it, ``sample_per_stratum`` (stratified random) applies.
|
|
368
|
+
sample_per_stratum: Optional CLI override (``--sample-per-stratum``) for
|
|
369
|
+
``dataset.sample_per_stratum`` — keep up to N rows per stratum
|
|
370
|
+
(stratum = ``dataset.stratify_field``, default ``expected_skill``).
|
|
371
|
+
Lets a runner cap a stratified dataset without editing the task YAML
|
|
372
|
+
(the nightly activation suite uses this). Ignored when ``max_rows``
|
|
373
|
+
is set. When None, falls back to ``dataset.sample_per_stratum``.
|
|
374
|
+
|
|
375
|
+
Returns:
|
|
376
|
+
Expanded list of TaskDefinitions. Length is 1 when dataset is None.
|
|
377
|
+
|
|
378
|
+
Raises:
|
|
379
|
+
ValueError: Empty dataset, duplicate row ids, missing id_field, or
|
|
380
|
+
malformed row id.
|
|
381
|
+
FileNotFoundError: Dataset path does not exist.
|
|
382
|
+
"""
|
|
383
|
+
if task.dataset is None:
|
|
384
|
+
return [task]
|
|
385
|
+
|
|
386
|
+
rows = _load_dataset_rows(task.dataset, task_file_dir)
|
|
387
|
+
if not rows:
|
|
388
|
+
raise ValueError(f"Dataset for task '{task.task_id}' is empty")
|
|
389
|
+
|
|
390
|
+
# Row selection precedence:
|
|
391
|
+
# 1. CLI --sample (max_rows): flat uniform-random N over the whole dataset.
|
|
392
|
+
# Fixed seed => reproducible across runs, but (unlike a first-N slice)
|
|
393
|
+
# unbiased across the concatenated dataset.paths.
|
|
394
|
+
# 2. sample_per_stratum: stratified random N-per-stratum. CLI
|
|
395
|
+
# --sample-per-stratum (the arg) overrides dataset.sample_per_stratum
|
|
396
|
+
# (the YAML), so a runner can cap a dataset without editing its task.
|
|
397
|
+
ds = task.dataset
|
|
398
|
+
n_per_stratum = sample_per_stratum if sample_per_stratum is not None else ds.sample_per_stratum
|
|
399
|
+
# Stratified sampling is seeded only by dataset.sample_seed. When that is None the sample is
|
|
400
|
+
# deliberately nondeterministic — re-drawn every run — regardless of whether the CLI
|
|
401
|
+
# --sample-per-stratum flag or the YAML supplied the count (see Dataset.sample_seed). The
|
|
402
|
+
# nightly activation suite relies on this to broaden coverage across runs.
|
|
403
|
+
stratum_seed = ds.sample_seed
|
|
404
|
+
if max_rows is not None and max_rows < len(rows):
|
|
405
|
+
rows = random.Random(_SMOKE_SAMPLE_SEED).sample(rows, max_rows)
|
|
406
|
+
elif max_rows is None and n_per_stratum is not None:
|
|
407
|
+
rows = _stratified_sample(rows, ds.stratify_field, n_per_stratum, stratum_seed)
|
|
408
|
+
|
|
409
|
+
id_field = task.dataset.id_field
|
|
410
|
+
seen_ids: set[str] = set()
|
|
411
|
+
expanded: list[TaskDefinition] = []
|
|
412
|
+
|
|
413
|
+
for i, row in enumerate(rows):
|
|
414
|
+
if id_field not in row:
|
|
415
|
+
raise ValueError(f"Dataset row {i} for task '{task.task_id}' missing id_field '{id_field}': {row}")
|
|
416
|
+
row_id = str(row[id_field])
|
|
417
|
+
if not _ROW_ID_PATTERN.match(row_id):
|
|
418
|
+
raise ValueError(
|
|
419
|
+
f"Dataset row id {row_id!r} must match {_ROW_ID_PATTERN.pattern}"
|
|
420
|
+
+ " (letters, digits, underscore, hyphen, dot)"
|
|
421
|
+
)
|
|
422
|
+
if row_id in seen_ids:
|
|
423
|
+
raise ValueError(f"Duplicate dataset row id for task '{task.task_id}': {row_id!r}")
|
|
424
|
+
seen_ids.add(row_id)
|
|
425
|
+
|
|
426
|
+
data = task.model_dump(exclude_unset=True)
|
|
427
|
+
if isinstance(data.get("initial_prompt"), str):
|
|
428
|
+
data["initial_prompt"] = _substitute_row_in_str(data["initial_prompt"], row)
|
|
429
|
+
if isinstance(data.get("success_criteria"), list):
|
|
430
|
+
data["success_criteria"] = [_substitute_row_in_tree(c, row) for c in data["success_criteria"]]
|
|
431
|
+
data["suite_id"] = task.task_id
|
|
432
|
+
data["row_id"] = row_id
|
|
433
|
+
data["task_id"] = f"{task.task_id}/{row_id}"
|
|
434
|
+
data["dataset"] = None
|
|
435
|
+
expanded.append(TaskDefinition(**data))
|
|
436
|
+
|
|
437
|
+
return expanded
|