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,631 @@
|
|
|
1
|
+
"""Task definition and configuration models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import warnings
|
|
7
|
+
from typing import Any, Literal, Self
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
10
|
+
|
|
11
|
+
from coder_eval.models.agent_config import ResolvedAgentConfig
|
|
12
|
+
from coder_eval.models.criteria import SuccessCriterion
|
|
13
|
+
from coder_eval.models.enums import AgentKind
|
|
14
|
+
from coder_eval.models.limits import RunLimits
|
|
15
|
+
from coder_eval.models.merge_strategy import MergeField
|
|
16
|
+
from coder_eval.models.sandbox import SandboxConfig
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class UnknownTaskFieldWarning(DeprecationWarning):
|
|
20
|
+
"""Emitted by TaskDefinition for unknown top-level fields under the soft-launch
|
|
21
|
+
policy in ``_warn_on_unknown_fields``. Dedicated subclass so callers (e.g.
|
|
22
|
+
``coder-eval plan``) can match by ``issubclass`` instead of substring-matching
|
|
23
|
+
the message text — and so other ``DeprecationWarning``s emitted during load
|
|
24
|
+
don't get conflated with stale-field signal.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
DEFAULT_SIMULATION_STOP_TOKEN = "<<<END>>>"
|
|
29
|
+
"""Sentinel token the user simulator emits when it considers the task complete."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
CriteriaCheckTiming = Literal["end_of_dialog", "every_turn", "both"]
|
|
33
|
+
"""When success criteria are evaluated inside a simulated dialog."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SimulationConfig(BaseModel):
|
|
37
|
+
"""Configuration for multi-turn user simulation.
|
|
38
|
+
|
|
39
|
+
When present on a TaskDefinition, the orchestrator replaces its single-shot
|
|
40
|
+
iteration loop with a dialog between the coding agent and a simulated user
|
|
41
|
+
(a second LLM). The simulator is configured with a persona, a goal, and
|
|
42
|
+
optional behavioral constraints; it generates each subsequent user prompt
|
|
43
|
+
after the first (which is still the task's ``initial_prompt``).
|
|
44
|
+
|
|
45
|
+
Semantics relative to the existing task fields:
|
|
46
|
+
- ``max_turns`` (below) bounds the intra-dialog agent<->user exchanges.
|
|
47
|
+
|
|
48
|
+
Security: ``persona``, ``goal``, and ``constraints`` are passed to the
|
|
49
|
+
simulator only. The task's ``reference`` is NEVER passed to the simulator,
|
|
50
|
+
mirroring the guarantee already in place for the coding agent.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
model_config = ConfigDict(extra="forbid")
|
|
54
|
+
|
|
55
|
+
enabled: bool = Field(default=False, description="Master switch — when false, simulation is skipped entirely.")
|
|
56
|
+
|
|
57
|
+
# The simulator runs as a tools-disabled Claude Code agent sharing the
|
|
58
|
+
# coding agent's ApiRoute, so model/temperature/max_tokens are resolved at
|
|
59
|
+
# the route level and are not configured here.
|
|
60
|
+
|
|
61
|
+
# Persona / goal.
|
|
62
|
+
persona: str = Field(
|
|
63
|
+
description=(
|
|
64
|
+
"Who the simulator is roleplaying — background, tone, level of domain knowledge. "
|
|
65
|
+
"Passed verbatim into the simulator system prompt."
|
|
66
|
+
),
|
|
67
|
+
)
|
|
68
|
+
goal: str = Field(
|
|
69
|
+
description=(
|
|
70
|
+
"What the simulated user wants to achieve. This drives the conversation. "
|
|
71
|
+
"You can optionally withhold information (see ``constraints``) to force clarification behavior."
|
|
72
|
+
),
|
|
73
|
+
)
|
|
74
|
+
constraints: list[str] = MergeField(
|
|
75
|
+
strategy="replace", # each layer's constraints wholesale-replace lower layers'
|
|
76
|
+
default_factory=list,
|
|
77
|
+
description=(
|
|
78
|
+
"Optional behavioral rules the simulator must follow "
|
|
79
|
+
"(e.g., 'do not paste code', 'reveal requirement X only if asked')."
|
|
80
|
+
),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Termination.
|
|
84
|
+
max_turns: int = Field(
|
|
85
|
+
default=8,
|
|
86
|
+
ge=1,
|
|
87
|
+
le=100,
|
|
88
|
+
description="Hard cap on simulated user<->agent exchanges within a single dialog.",
|
|
89
|
+
)
|
|
90
|
+
stop_token: str = Field(
|
|
91
|
+
default=DEFAULT_SIMULATION_STOP_TOKEN,
|
|
92
|
+
min_length=1,
|
|
93
|
+
description="If the simulator emits a message containing this token, the dialog ends.",
|
|
94
|
+
)
|
|
95
|
+
stop_on_criteria_pass: bool = Field(
|
|
96
|
+
default=False,
|
|
97
|
+
description=(
|
|
98
|
+
"When True, end the dialog as soon as all success criteria pass. "
|
|
99
|
+
"Requires ``check_criteria: every_turn`` or ``both``."
|
|
100
|
+
),
|
|
101
|
+
)
|
|
102
|
+
max_total_tokens: int | None = Field(
|
|
103
|
+
default=None,
|
|
104
|
+
gt=0,
|
|
105
|
+
description=(
|
|
106
|
+
"Optional budget across the whole dialog (sum of simulator + agent tokens). "
|
|
107
|
+
"When exceeded, the dialog terminates with stop_reason='budget'."
|
|
108
|
+
),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# Sampling.
|
|
112
|
+
n_trials: int = Field(
|
|
113
|
+
default=1,
|
|
114
|
+
ge=1,
|
|
115
|
+
le=100,
|
|
116
|
+
description="Number of independent dialog trajectories to run per (task, variant).",
|
|
117
|
+
)
|
|
118
|
+
parallel_trials: bool = Field(
|
|
119
|
+
default=True,
|
|
120
|
+
description="When True, trials run concurrently (subject to batch max_parallel).",
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# Criteria timing.
|
|
124
|
+
check_criteria: CriteriaCheckTiming = Field(
|
|
125
|
+
default="end_of_dialog",
|
|
126
|
+
description=(
|
|
127
|
+
"When to evaluate success criteria: end_of_dialog (cheapest), "
|
|
128
|
+
"every_turn (enables early stop), or both (record every turn AND at end)."
|
|
129
|
+
),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
@field_validator("persona", "goal")
|
|
133
|
+
@classmethod
|
|
134
|
+
def _non_blank(cls, v: str) -> str:
|
|
135
|
+
if not v or not v.strip():
|
|
136
|
+
raise ValueError("must be a non-empty string")
|
|
137
|
+
return v
|
|
138
|
+
|
|
139
|
+
@model_validator(mode="after")
|
|
140
|
+
def _validate_timing(self) -> Self:
|
|
141
|
+
if self.stop_on_criteria_pass and self.check_criteria == "end_of_dialog":
|
|
142
|
+
raise ValueError(
|
|
143
|
+
"simulation.stop_on_criteria_pass=True requires check_criteria='every_turn' or 'both' — "
|
|
144
|
+
+ "set check_criteria accordingly, or disable stop_on_criteria_pass."
|
|
145
|
+
)
|
|
146
|
+
return self
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class ReferenceSource(BaseModel):
|
|
150
|
+
"""Defines the source for the reference solution.
|
|
151
|
+
|
|
152
|
+
The reference is NEVER shown to the agent being evaluated. It is used by:
|
|
153
|
+
|
|
154
|
+
- ``LLMJudgeCriterion``: inlines the reference content into the judge prompt
|
|
155
|
+
(string forms only — ``code`` or ``file``).
|
|
156
|
+
- ``ReferenceComparisonCriterion``: computes AST/token similarity (string forms only).
|
|
157
|
+
- ``AgentJudgeCriterion``: with ``include_reference=true``, the reference is
|
|
158
|
+
copied into the judge sub-agent's working directory (single file inlined
|
|
159
|
+
into the prompt for ``code``/``file``; ``directory`` is mounted at
|
|
160
|
+
``_reference/`` for the judge to ``Glob``/``Read`` over).
|
|
161
|
+
|
|
162
|
+
Exactly one of ``code``, ``file``, or ``directory`` must be provided.
|
|
163
|
+
Security: reference solutions must never leak into agent prompts or logs.
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
model_config = ConfigDict(extra="forbid")
|
|
167
|
+
|
|
168
|
+
code: str | None = Field(default=None, description="Inline reference code (for simple, short solutions)")
|
|
169
|
+
file: str | None = Field(default=None, description="Path to file containing reference code (relative to task YAML)")
|
|
170
|
+
directory: str | None = Field(
|
|
171
|
+
default=None,
|
|
172
|
+
description=(
|
|
173
|
+
"Path to a directory containing the reference solution (relative to task YAML). "
|
|
174
|
+
"Only consumed by agent_judge — the judge gets a read-only copy at "
|
|
175
|
+
"``_reference/`` in its working directory and can browse it with Glob/Read. "
|
|
176
|
+
"llm_judge and reference_comparison only accept string forms (code/file)."
|
|
177
|
+
),
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
@model_validator(mode="after")
|
|
181
|
+
def check_exclusive_source(self) -> Self:
|
|
182
|
+
"""Ensure exactly one of code / file / directory is provided."""
|
|
183
|
+
provided = sum(1 for v in (self.code, self.file, self.directory) if v is not None)
|
|
184
|
+
if provided > 1:
|
|
185
|
+
raise ValueError("Only one of 'code', 'file', or 'directory' can be provided for reference.")
|
|
186
|
+
if provided == 0:
|
|
187
|
+
raise ValueError("One of 'code', 'file', or 'directory' must be provided for reference.")
|
|
188
|
+
return self
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class Dataset(BaseModel):
|
|
192
|
+
"""Dataset that fans out a single task into N sub-tasks, one per row.
|
|
193
|
+
|
|
194
|
+
Exactly one of ``rows`` (inline list of dicts) or ``paths`` (one or more
|
|
195
|
+
JSONL files concatenated in declared order) must be provided. Each row
|
|
196
|
+
must contain the field named by ``id_field``; that value is used as the
|
|
197
|
+
stable row identifier and becomes a suffix on the task_id
|
|
198
|
+
("<task_id>/<row.id>").
|
|
199
|
+
|
|
200
|
+
Row values are substituted into the task's ``initial_prompt`` and into
|
|
201
|
+
string fields of each ``success_criteria`` entry using ``${row.<field>}``
|
|
202
|
+
syntax. Substitution happens in ``task_loader.expand_dataset`` before
|
|
203
|
+
variant resolution, so variants cannot override the dataset.
|
|
204
|
+
"""
|
|
205
|
+
|
|
206
|
+
model_config = ConfigDict(extra="forbid")
|
|
207
|
+
|
|
208
|
+
paths: list[str] | None = Field(
|
|
209
|
+
default=None,
|
|
210
|
+
description=(
|
|
211
|
+
"JSONL file paths (relative to the task YAML), concatenated into a single "
|
|
212
|
+
"row stream in declared order. Mutually exclusive with 'rows'."
|
|
213
|
+
),
|
|
214
|
+
)
|
|
215
|
+
rows: list[dict[str, Any]] | None = Field(
|
|
216
|
+
default=None,
|
|
217
|
+
description="Inline list of row dicts. Mutually exclusive with 'paths'.",
|
|
218
|
+
)
|
|
219
|
+
id_field: str = Field(
|
|
220
|
+
default="id",
|
|
221
|
+
description="Field in each row to use as the row identifier (default: 'id').",
|
|
222
|
+
)
|
|
223
|
+
sample_per_stratum: int | None = Field(
|
|
224
|
+
default=None,
|
|
225
|
+
ge=1,
|
|
226
|
+
description=(
|
|
227
|
+
"Stratified random sample: keep up to N rows per stratum, where the stratum is the "
|
|
228
|
+
"value of 'stratify_field' on each row. Strata with <= N rows are taken whole. "
|
|
229
|
+
"Designed for classification datasets (e.g. activation) where the metric is computed "
|
|
230
|
+
"per stratum, so a uniform sample would starve rare strata. CLI '--sample' (a flat "
|
|
231
|
+
"uniform-random N over the whole dataset) overrides this when set."
|
|
232
|
+
),
|
|
233
|
+
)
|
|
234
|
+
stratify_field: str = Field(
|
|
235
|
+
default="expected_skill",
|
|
236
|
+
description="Row field whose value defines the stratum for 'sample_per_stratum' (default: 'expected_skill').",
|
|
237
|
+
)
|
|
238
|
+
sample_seed: int | None = Field(
|
|
239
|
+
default=None,
|
|
240
|
+
description=(
|
|
241
|
+
"(re-draws every night; broadens coverage for the nightly suites) — this holds whether "
|
|
242
|
+
"the count comes from YAML or the CLI --sample-per-stratum flag. NOTE: unlike CLI --sample "
|
|
243
|
+
"(fixed-seed, reproducible by default), the stratified sample_per_stratum draw is "
|
|
244
|
+
"NONDETERMINISTIC by default; set an integer here to pin a reproducible sample."
|
|
245
|
+
),
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
@model_validator(mode="after")
|
|
249
|
+
def check_source(self) -> Self:
|
|
250
|
+
if self.paths is None and self.rows is None:
|
|
251
|
+
raise ValueError("Dataset must specify either 'paths' or 'rows'")
|
|
252
|
+
if self.paths is not None and self.rows is not None:
|
|
253
|
+
raise ValueError("Dataset must specify only one of 'paths' or 'rows'")
|
|
254
|
+
if self.paths is not None and not self.paths:
|
|
255
|
+
raise ValueError("Dataset.paths must be a non-empty list")
|
|
256
|
+
return self
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class PostRunCommand(BaseModel):
|
|
260
|
+
"""A command to execute after evaluation completes.
|
|
261
|
+
|
|
262
|
+
Post-run commands run inside the sandbox after the evaluation verdict is finalized.
|
|
263
|
+
They do NOT affect pass/fail status — they are for artifact generation, data extraction,
|
|
264
|
+
cleanup, or any side effects needed after the run.
|
|
265
|
+
"""
|
|
266
|
+
|
|
267
|
+
model_config = ConfigDict(extra="forbid")
|
|
268
|
+
|
|
269
|
+
command: str = Field(description="Shell command to execute (run via shell, supports pipes/redirects)")
|
|
270
|
+
timeout: int = Field(default=30, ge=1, le=300, description="Maximum seconds to wait for the command to complete")
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
class PreRunCommand(BaseModel):
|
|
274
|
+
"""A command to execute before agent evaluation starts.
|
|
275
|
+
|
|
276
|
+
Pre-run commands run inside the sandbox after setup completes but before
|
|
277
|
+
the agent starts. By default (fail_on_error=True), a non-zero exit code,
|
|
278
|
+
timeout, or exception aborts the evaluation with FinalStatus.ERROR — the
|
|
279
|
+
agent should not run against a broken environment.
|
|
280
|
+
"""
|
|
281
|
+
|
|
282
|
+
model_config = ConfigDict(extra="forbid")
|
|
283
|
+
|
|
284
|
+
command: str = Field(description="Shell command to execute (run via shell, supports pipes/redirects)")
|
|
285
|
+
timeout: int = Field(default=30, ge=1, le=300, description="Maximum seconds to wait for the command to complete")
|
|
286
|
+
fail_on_error: bool = Field(
|
|
287
|
+
default=True,
|
|
288
|
+
description=(
|
|
289
|
+
"When True (default), a non-zero exit code, timeout, or exception aborts the evaluation "
|
|
290
|
+
"with FinalStatus.ERROR. Set to False for optional/informational setup commands."
|
|
291
|
+
),
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
class TaskDefinition(BaseModel): # noqa: CE009 -- soft-launch: see _warn_on_unknown_fields below
|
|
296
|
+
"""Complete definition of an evaluation task.
|
|
297
|
+
|
|
298
|
+
Unlike ``ReferenceSource`` / ``BaseSuccessCriterion`` / sibling models in
|
|
299
|
+
this file, ``TaskDefinition`` does NOT declare ``extra='forbid'``: this is
|
|
300
|
+
the top-level user-facing input and downstream consumers (notably the
|
|
301
|
+
``~/src/skills/tests/tasks/`` repo) carry a long tail of stale fields
|
|
302
|
+
(``max_iterations``, ``llm_reviewer``, …) that used to be silently
|
|
303
|
+
dropped. Flipping to forbid would break those task YAMLs in lockstep,
|
|
304
|
+
which is too disruptive to ship in a single PR.
|
|
305
|
+
|
|
306
|
+
Instead, ``_warn_on_unknown_fields`` (below) logs a ``DeprecationWarning``
|
|
307
|
+
naming each unknown top-level key, surfacing the typo to authors without
|
|
308
|
+
blocking the run. Once the downstream backlog is cleared, this can be
|
|
309
|
+
tightened to ``extra='forbid'`` and the ``# noqa: CE009`` suppression
|
|
310
|
+
removed.
|
|
311
|
+
"""
|
|
312
|
+
|
|
313
|
+
task_id: str = Field(description="Unique identifier for this task")
|
|
314
|
+
description: str = Field(description="Human-readable description of what the task is testing")
|
|
315
|
+
initial_prompt: str | None = Field(
|
|
316
|
+
default=None,
|
|
317
|
+
description="The initial prompt to send to the agent. Mutually exclusive with initial_prompt_file.",
|
|
318
|
+
)
|
|
319
|
+
initial_prompt_file: str | None = Field(
|
|
320
|
+
default=None,
|
|
321
|
+
description=(
|
|
322
|
+
"Path to a file containing the initial prompt (relative to task YAML). "
|
|
323
|
+
"Mutually exclusive with initial_prompt."
|
|
324
|
+
),
|
|
325
|
+
)
|
|
326
|
+
tags: list[str] = MergeField(
|
|
327
|
+
strategy="replace", # not layer-merged today; replace = the engine default if it ever is
|
|
328
|
+
default_factory=list,
|
|
329
|
+
description=(
|
|
330
|
+
"Tags for categorizing and filtering tasks. Each tag is kebab-case, optionally namespaced "
|
|
331
|
+
"as 'key:value' where both key and value are kebab-case (e.g., 'smoke', 'lifecycle:generate')."
|
|
332
|
+
),
|
|
333
|
+
)
|
|
334
|
+
skip: bool = Field(
|
|
335
|
+
default=False,
|
|
336
|
+
description=(
|
|
337
|
+
"When true, the task is recorded in RunSummary.skipped_tasks at resolution time and "
|
|
338
|
+
"never reaches the orchestrator. Use to quarantine known-blocked tasks without deleting "
|
|
339
|
+
"the YAML — pair with a comment citing the blocker (e.g. Jira link, upstream dependency)."
|
|
340
|
+
),
|
|
341
|
+
)
|
|
342
|
+
# ResolvedAgentConfig = base-typed + registry-driven dict coercion + SerializeAsAny:
|
|
343
|
+
# the concrete subclass (built-in or plugin) is chosen by parse_agent_config, not a
|
|
344
|
+
# static discriminated union, so any registered plugin kind validates here and its
|
|
345
|
+
# subclass-only fields (e.g. sdk_options) survive model_dump().
|
|
346
|
+
agent: ResolvedAgentConfig | None = Field(
|
|
347
|
+
default=None,
|
|
348
|
+
description=(
|
|
349
|
+
"Agent configuration (resolved from experiment if omitted). Set `agent: {type: none}` for a "
|
|
350
|
+
"no-op / system task: no coding agent runs and no model API call is made — coder-eval sets up "
|
|
351
|
+
"the sandbox, executes pre_run, and checks the success_criteria directly. Use for system / "
|
|
352
|
+
"canary checks (e.g. verifying Orchestrator or Integration Service connectivity) that reuse the "
|
|
353
|
+
"eval infrastructure without involving an agent. A `type: none` task must declare no "
|
|
354
|
+
"`initial_prompt` / `initial_prompt_file` and no `simulation` (no agent reads them), and every "
|
|
355
|
+
"criterion must be agent-independent (no `requires_agent` criteria such as command_executed / "
|
|
356
|
+
"skill_triggered / reference_comparison / commands_efficiency)."
|
|
357
|
+
),
|
|
358
|
+
)
|
|
359
|
+
sandbox: SandboxConfig = Field(
|
|
360
|
+
default_factory=SandboxConfig,
|
|
361
|
+
description="Sandbox configuration (defaults to tempdir if omitted)",
|
|
362
|
+
)
|
|
363
|
+
success_criteria: list[SuccessCriterion] = MergeField(
|
|
364
|
+
strategy="replace", # not layer-merged today; replace = the engine default if it ever is
|
|
365
|
+
description="List of criteria that must all pass for task success",
|
|
366
|
+
)
|
|
367
|
+
run_limits: RunLimits | None = Field(
|
|
368
|
+
default=None,
|
|
369
|
+
description=(
|
|
370
|
+
"Run-time caps (turns, wall-clock, tokens, USD) that abort the task when exceeded. See RunLimits."
|
|
371
|
+
),
|
|
372
|
+
)
|
|
373
|
+
reference: ReferenceSource | None = Field(
|
|
374
|
+
default=None,
|
|
375
|
+
description=(
|
|
376
|
+
"Reference solution for llm_judge / agent_judge and reference_comparison criteria. "
|
|
377
|
+
"HIDDEN from the agent - never included in prompts."
|
|
378
|
+
),
|
|
379
|
+
)
|
|
380
|
+
expected_commands: int | None = Field(
|
|
381
|
+
default=None,
|
|
382
|
+
ge=1,
|
|
383
|
+
description="Expected number of tool commands for orchestrator-level efficiency tracking",
|
|
384
|
+
)
|
|
385
|
+
pre_run: list[PreRunCommand] = MergeField(
|
|
386
|
+
strategy="append",
|
|
387
|
+
default_factory=list,
|
|
388
|
+
description=(
|
|
389
|
+
"Commands to execute before agent evaluation starts. "
|
|
390
|
+
"By default a failing command aborts evaluation with FinalStatus.ERROR. "
|
|
391
|
+
"Set fail_on_error=False per command to make it informational. "
|
|
392
|
+
"Experiment-defaults pre_run runs first (baseline setup), then the task's."
|
|
393
|
+
),
|
|
394
|
+
)
|
|
395
|
+
post_run: list[PostRunCommand] = MergeField(
|
|
396
|
+
strategy="append",
|
|
397
|
+
append_order="reverse",
|
|
398
|
+
default_factory=list,
|
|
399
|
+
description=(
|
|
400
|
+
"Commands to execute after evaluation completes. Do not affect pass/fail. "
|
|
401
|
+
"The task's commands run first, then experiment-defaults post_run (cleanup-last)."
|
|
402
|
+
),
|
|
403
|
+
)
|
|
404
|
+
dataset: Dataset | None = Field(
|
|
405
|
+
default=None,
|
|
406
|
+
description=(
|
|
407
|
+
"Optional dataset to fan out this task into one sub-task per row. "
|
|
408
|
+
"Row values substitute into initial_prompt and success_criteria via ${row.<field>}. "
|
|
409
|
+
"Expansion happens before variant resolution, so variants cannot override the dataset."
|
|
410
|
+
),
|
|
411
|
+
)
|
|
412
|
+
suite_id: str | None = Field(
|
|
413
|
+
default=None,
|
|
414
|
+
description=(
|
|
415
|
+
"Set by the dataset expander on expanded row-tasks to the original task_id. "
|
|
416
|
+
"Signal for suite-level pass-rate rollup reporting."
|
|
417
|
+
),
|
|
418
|
+
)
|
|
419
|
+
row_id: str | None = Field(
|
|
420
|
+
default=None,
|
|
421
|
+
description="Set by the dataset expander on expanded row-tasks to the value from Dataset.id_field.",
|
|
422
|
+
)
|
|
423
|
+
simulation: SimulationConfig | None = Field(
|
|
424
|
+
default=None,
|
|
425
|
+
description=(
|
|
426
|
+
"Optional multi-turn user-simulation config. When present and enabled, the orchestrator runs "
|
|
427
|
+
"the task as a dialog between the coding agent and a simulated user LLM instead of "
|
|
428
|
+
"the default single-shot iteration loop."
|
|
429
|
+
),
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
@model_validator(mode="before")
|
|
433
|
+
@classmethod
|
|
434
|
+
def _warn_on_unknown_fields(cls, data: Any) -> Any:
|
|
435
|
+
"""Soft replacement for ``extra='forbid'`` — log a warning per unknown key.
|
|
436
|
+
|
|
437
|
+
``TaskDefinition`` cannot flip to strict mode in this PR without breaking
|
|
438
|
+
the downstream ``~/src/skills/tests/tasks/`` task repo, which carries a
|
|
439
|
+
long tail of stale top-level fields (``max_iterations``, ``llm_reviewer``,
|
|
440
|
+
…) that used to be silently dropped. This validator surfaces each unknown
|
|
441
|
+
key as a ``DeprecationWarning`` so authors see the typo at load time,
|
|
442
|
+
without blocking the run.
|
|
443
|
+
|
|
444
|
+
Skips computed / writer-only fields that ``model_dump`` round-trips but
|
|
445
|
+
loaders typically omit. The known-fields set is derived from
|
|
446
|
+
``cls.model_fields`` so it stays in sync if the model grows.
|
|
447
|
+
"""
|
|
448
|
+
if not isinstance(data, dict):
|
|
449
|
+
return data
|
|
450
|
+
known = set(cls.model_fields)
|
|
451
|
+
unknown = [k for k in data if k not in known]
|
|
452
|
+
for key in unknown:
|
|
453
|
+
warnings.warn(
|
|
454
|
+
f"TaskDefinition: unknown top-level field {key!r} will be ignored. "
|
|
455
|
+
+ "If this is a typo, fix it; if the field is stale from a previous "
|
|
456
|
+
+ "schema, remove it. Future versions may reject unknown fields.",
|
|
457
|
+
UnknownTaskFieldWarning,
|
|
458
|
+
stacklevel=2,
|
|
459
|
+
)
|
|
460
|
+
return data
|
|
461
|
+
|
|
462
|
+
@property
|
|
463
|
+
def is_none_agent(self) -> bool:
|
|
464
|
+
"""True when this task runs the no-op agent (``agent: {type: none}``).
|
|
465
|
+
|
|
466
|
+
The single signal for "no coding agent runs" — keyed off ``agent.type``,
|
|
467
|
+
not a separate flag. Note this is only meaningful after the agent block
|
|
468
|
+
is present; an unresolved task that omits ``agent`` entirely is False.
|
|
469
|
+
"""
|
|
470
|
+
return self.agent is not None and self.agent.type == AgentKind.NONE
|
|
471
|
+
|
|
472
|
+
@model_validator(mode="after")
|
|
473
|
+
def check_prompt_fields(self) -> Self:
|
|
474
|
+
"""Validate initial_prompt / initial_prompt_file combination.
|
|
475
|
+
|
|
476
|
+
- initial_prompt and initial_prompt_file are always mutually exclusive.
|
|
477
|
+
- Exactly one is required for single-shot tasks.
|
|
478
|
+
- In simulation mode (``simulation.enabled=True``) both may be omitted;
|
|
479
|
+
the simulator then generates the opening user utterance from its
|
|
480
|
+
persona and goal.
|
|
481
|
+
- No-op (``agent: {type: none}``) tasks need no prompt — no agent reads
|
|
482
|
+
it. (``check_none_agent`` additionally rejects a prompt if one is set.)
|
|
483
|
+
"""
|
|
484
|
+
if self.initial_prompt is not None and self.initial_prompt_file is not None:
|
|
485
|
+
raise ValueError("Only one of 'initial_prompt' or 'initial_prompt_file' can be provided, not both")
|
|
486
|
+
in_simulation = self.simulation is not None and self.simulation.enabled
|
|
487
|
+
if (
|
|
488
|
+
self.initial_prompt is None
|
|
489
|
+
and self.initial_prompt_file is None
|
|
490
|
+
and not in_simulation
|
|
491
|
+
and not self.is_none_agent
|
|
492
|
+
):
|
|
493
|
+
raise ValueError(
|
|
494
|
+
"Either 'initial_prompt' or 'initial_prompt_file' must be provided "
|
|
495
|
+
+ "(unless 'simulation.enabled' is true, in which case the simulator generates the opener, "
|
|
496
|
+
+ "or 'agent.type' is 'none', in which case no agent runs)"
|
|
497
|
+
)
|
|
498
|
+
return self
|
|
499
|
+
|
|
500
|
+
@model_validator(mode="after")
|
|
501
|
+
def check_none_agent(self) -> Self:
|
|
502
|
+
"""Validate the no-op agent (``agent: {type: none}``) contract.
|
|
503
|
+
|
|
504
|
+
A ``type: none`` task runs no coding agent and makes no model API call, so:
|
|
505
|
+
|
|
506
|
+
- It must declare no ``initial_prompt`` / ``initial_prompt_file`` and no
|
|
507
|
+
enabled ``simulation`` — there is no agent to read a prompt or hold a
|
|
508
|
+
dialog, so setting them is a silent no-op; reject loudly instead.
|
|
509
|
+
- Every criterion must be agent-independent: criteria with
|
|
510
|
+
``requires_agent=True`` (e.g. command_executed, skill_triggered,
|
|
511
|
+
reference_comparison, commands_efficiency) inspect the agent's
|
|
512
|
+
trajectory, which does not exist when no agent runs.
|
|
513
|
+
"""
|
|
514
|
+
if not self.is_none_agent:
|
|
515
|
+
return self
|
|
516
|
+
if self.initial_prompt is not None or self.initial_prompt_file is not None:
|
|
517
|
+
raise ValueError(
|
|
518
|
+
"A 'type: none' task must not set 'initial_prompt' / 'initial_prompt_file' "
|
|
519
|
+
+ "(no agent runs to read it). Remove the prompt or pick a real agent type."
|
|
520
|
+
)
|
|
521
|
+
if self.simulation is not None and self.simulation.enabled:
|
|
522
|
+
raise ValueError(
|
|
523
|
+
"A 'type: none' task cannot enable 'simulation' (no agent for the simulator to talk to). "
|
|
524
|
+
+ "Remove the simulation block or pick a real agent type."
|
|
525
|
+
)
|
|
526
|
+
offending = sorted({c.type for c in self.success_criteria if c.requires_agent})
|
|
527
|
+
if offending:
|
|
528
|
+
raise ValueError(
|
|
529
|
+
f"A 'type: none' task cannot use criteria that require an agent trajectory: {offending}. "
|
|
530
|
+
+ "Use agent-independent criteria (run_command, file_exists, file_contains, json_check, "
|
|
531
|
+
+ "file_matches_regex, file_check, ...)."
|
|
532
|
+
)
|
|
533
|
+
return self
|
|
534
|
+
|
|
535
|
+
@model_validator(mode="after")
|
|
536
|
+
def check_directory_reference_compatibility(self) -> Self:
|
|
537
|
+
"""Reject ``reference.directory`` paired with criteria that need a string reference.
|
|
538
|
+
|
|
539
|
+
``reference_comparison`` and ``llm_judge`` only consume ``reference_code``
|
|
540
|
+
(the string forms ``code`` / ``file``). When ``reference.directory`` is
|
|
541
|
+
the only form set, those criteria silently degrade — ``reference_comparison``
|
|
542
|
+
deterministically scores 0.0 ("No reference code provided") and
|
|
543
|
+
``llm_judge`` runs without the reference even when ``include_reference=True``.
|
|
544
|
+
Catch the misconfiguration at load time instead of at run time.
|
|
545
|
+
"""
|
|
546
|
+
if self.reference is None or self.reference.directory is None:
|
|
547
|
+
return self
|
|
548
|
+
offenders: list[str] = []
|
|
549
|
+
for c in self.success_criteria:
|
|
550
|
+
ctype = c.type
|
|
551
|
+
if ctype == "reference_comparison":
|
|
552
|
+
offenders.append("reference_comparison")
|
|
553
|
+
elif ctype == "llm_judge" and getattr(c, "include_reference", False):
|
|
554
|
+
offenders.append("llm_judge (include_reference=true)")
|
|
555
|
+
if offenders:
|
|
556
|
+
raise ValueError(
|
|
557
|
+
"reference.directory is only consumed by agent_judge; the following "
|
|
558
|
+
+ f"criteria require a string reference (use 'code' or 'file' instead): {offenders}"
|
|
559
|
+
)
|
|
560
|
+
return self
|
|
561
|
+
|
|
562
|
+
@model_validator(mode="after")
|
|
563
|
+
def check_suite_thresholds_require_dataset(self) -> Self:
|
|
564
|
+
"""Across-row suite_thresholds only make sense for dataset-backed tasks.
|
|
565
|
+
|
|
566
|
+
Skipped for expanded row-tasks (``suite_id`` set), which have dataset
|
|
567
|
+
cleared by design — the original parent task already passed this check.
|
|
568
|
+
"""
|
|
569
|
+
if self.dataset is None and self.suite_id is None:
|
|
570
|
+
for c in self.success_criteria:
|
|
571
|
+
if getattr(c, "suite_thresholds", None):
|
|
572
|
+
raise ValueError(
|
|
573
|
+
f"success_criteria[{c.type!r}].suite_thresholds requires a dataset: block "
|
|
574
|
+
+ "(thresholds are evaluated on aggregated across-row metrics)"
|
|
575
|
+
)
|
|
576
|
+
return self
|
|
577
|
+
|
|
578
|
+
@field_validator("tags")
|
|
579
|
+
@classmethod
|
|
580
|
+
def validate_tags(cls, v: list[str]) -> list[str]:
|
|
581
|
+
"""Validate tags are kebab-case, optionally namespaced as 'key:value'.
|
|
582
|
+
|
|
583
|
+
Each side of an optional single colon must be lowercase kebab-case (a-z0-9, hyphens
|
|
584
|
+
between segments). Examples: 'smoke', 'uipath-python', 'lifecycle:generate',
|
|
585
|
+
'connector:google-tasks'. Rejected: leading/trailing colons, multiple colons,
|
|
586
|
+
underscores, uppercase, spaces.
|
|
587
|
+
"""
|
|
588
|
+
tag_pattern = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*(:[a-z0-9]+(-[a-z0-9]+)*)?$")
|
|
589
|
+
for tag in v:
|
|
590
|
+
if not tag_pattern.match(tag):
|
|
591
|
+
msg = (
|
|
592
|
+
f"Tag '{tag}' must be lowercase kebab-case, optionally namespaced as "
|
|
593
|
+
"'key:value' (e.g., 'smoke', 'uipath-python', 'lifecycle:generate')"
|
|
594
|
+
)
|
|
595
|
+
raise ValueError(msg)
|
|
596
|
+
return v
|
|
597
|
+
|
|
598
|
+
@field_validator("success_criteria", mode="before")
|
|
599
|
+
@classmethod
|
|
600
|
+
def check_removed_criteria_types(cls, v: Any) -> Any:
|
|
601
|
+
"""Normalize legacy criterion aliases and error on removed types."""
|
|
602
|
+
removed: dict[str, str] = {
|
|
603
|
+
"program_stdout_equals": "Use 'run_command' with 'expected_stdout' and 'stdout_match' instead.",
|
|
604
|
+
"code_lints": "Use 'run_command' to run your linter directly instead.",
|
|
605
|
+
"scored_command": "Use 'run_command' with 'score_from_stdout: true' instead.",
|
|
606
|
+
}
|
|
607
|
+
if isinstance(v, list):
|
|
608
|
+
normalized: list[Any] = []
|
|
609
|
+
for item in v:
|
|
610
|
+
if isinstance(item, dict):
|
|
611
|
+
ctype = item.get("type")
|
|
612
|
+
if ctype in removed:
|
|
613
|
+
raise ValueError(f"Criterion type '{ctype}' has been removed. {removed[ctype]}")
|
|
614
|
+
if ctype == "command_not_executed":
|
|
615
|
+
item = {
|
|
616
|
+
**item,
|
|
617
|
+
"type": "command_executed",
|
|
618
|
+
"min_count": 0,
|
|
619
|
+
"max_count": 0,
|
|
620
|
+
}
|
|
621
|
+
normalized.append(item)
|
|
622
|
+
return normalized
|
|
623
|
+
return v
|
|
624
|
+
|
|
625
|
+
@field_validator("success_criteria")
|
|
626
|
+
@classmethod
|
|
627
|
+
def validate_success_criteria(cls, v: Any) -> Any:
|
|
628
|
+
"""Ensure at least one success criterion is defined."""
|
|
629
|
+
if not v:
|
|
630
|
+
raise ValueError("At least one success criterion must be defined")
|
|
631
|
+
return v
|