coder-eval 0.8.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. coder_eval/.gitattributes +2 -0
  2. coder_eval/__init__.py +3 -0
  3. coder_eval/agent.py +302 -0
  4. coder_eval/agents/__init__.py +41 -0
  5. coder_eval/agents/_logging.py +89 -0
  6. coder_eval/agents/antigravity_agent.py +811 -0
  7. coder_eval/agents/claude_code_agent.py +1701 -0
  8. coder_eval/agents/codex_agent.py +2055 -0
  9. coder_eval/agents/noop_agent.py +100 -0
  10. coder_eval/agents/registry.py +163 -0
  11. coder_eval/agents/watchdog.py +116 -0
  12. coder_eval/analysis.py +88 -0
  13. coder_eval/cli/__init__.py +87 -0
  14. coder_eval/cli/aggregate_command.py +153 -0
  15. coder_eval/cli/console.py +7 -0
  16. coder_eval/cli/evaluate_command.py +164 -0
  17. coder_eval/cli/plan_command.py +152 -0
  18. coder_eval/cli/report_command.py +121 -0
  19. coder_eval/cli/run_command.py +686 -0
  20. coder_eval/cli/run_helpers.py +137 -0
  21. coder_eval/cli/run_task_internal_command.py +210 -0
  22. coder_eval/cli/utils.py +37 -0
  23. coder_eval/config.py +212 -0
  24. coder_eval/criteria/__init__.py +163 -0
  25. coder_eval/criteria/_classification_aggregate.py +106 -0
  26. coder_eval/criteria/agent_judge.py +425 -0
  27. coder_eval/criteria/base.py +248 -0
  28. coder_eval/criteria/classification_match.py +107 -0
  29. coder_eval/criteria/command_executed.py +181 -0
  30. coder_eval/criteria/commands_efficiency.py +73 -0
  31. coder_eval/criteria/file_check.py +119 -0
  32. coder_eval/criteria/file_contains.py +85 -0
  33. coder_eval/criteria/file_exists.py +45 -0
  34. coder_eval/criteria/file_matches_regex.py +86 -0
  35. coder_eval/criteria/json_check.py +184 -0
  36. coder_eval/criteria/llm_judge.py +260 -0
  37. coder_eval/criteria/reference_comparison.py +130 -0
  38. coder_eval/criteria/run_command.py +220 -0
  39. coder_eval/criteria/skill_triggered.py +111 -0
  40. coder_eval/criteria/uipath_eval.py +223 -0
  41. coder_eval/errors/__init__.py +26 -0
  42. coder_eval/errors/agent.py +26 -0
  43. coder_eval/errors/budget.py +28 -0
  44. coder_eval/errors/categories.py +205 -0
  45. coder_eval/errors/categorization.py +240 -0
  46. coder_eval/errors/executor.py +124 -0
  47. coder_eval/errors/judge.py +12 -0
  48. coder_eval/errors/retry.py +211 -0
  49. coder_eval/errors/timeout.py +63 -0
  50. coder_eval/evaluation/__init__.py +10 -0
  51. coder_eval/evaluation/checker.py +260 -0
  52. coder_eval/evaluation/judge_anthropic.py +55 -0
  53. coder_eval/evaluation/judge_bedrock.py +114 -0
  54. coder_eval/evaluation/judge_context.py +497 -0
  55. coder_eval/evaluation/judge_models.py +53 -0
  56. coder_eval/evaluation/judge_persistence.py +291 -0
  57. coder_eval/evaluation/judge_usage.py +50 -0
  58. coder_eval/evaluation/sub_agent.py +231 -0
  59. coder_eval/evaluation/summaries.py +48 -0
  60. coder_eval/evaluation/verdict_tool.py +213 -0
  61. coder_eval/formatting.py +191 -0
  62. coder_eval/isolation/__init__.py +15 -0
  63. coder_eval/isolation/docker_runner.py +1253 -0
  64. coder_eval/logging_config.py +399 -0
  65. coder_eval/models/__init__.py +337 -0
  66. coder_eval/models/agent_config.py +373 -0
  67. coder_eval/models/container_paths.py +26 -0
  68. coder_eval/models/criteria.py +942 -0
  69. coder_eval/models/enums.py +121 -0
  70. coder_eval/models/experiment.py +314 -0
  71. coder_eval/models/judge.py +90 -0
  72. coder_eval/models/judge_defaults.py +11 -0
  73. coder_eval/models/limits.py +89 -0
  74. coder_eval/models/merge_strategy.py +132 -0
  75. coder_eval/models/mutations.py +95 -0
  76. coder_eval/models/results.py +787 -0
  77. coder_eval/models/routing.py +160 -0
  78. coder_eval/models/sandbox.py +371 -0
  79. coder_eval/models/tasks.py +631 -0
  80. coder_eval/models/telemetry.py +454 -0
  81. coder_eval/models/templates.py +108 -0
  82. coder_eval/orchestration/__init__.py +13 -0
  83. coder_eval/orchestration/batch.py +690 -0
  84. coder_eval/orchestration/config.py +111 -0
  85. coder_eval/orchestration/config_merge.py +431 -0
  86. coder_eval/orchestration/evaluation.py +94 -0
  87. coder_eval/orchestration/experiment.py +837 -0
  88. coder_eval/orchestration/overrides.py +206 -0
  89. coder_eval/orchestration/task_loader.py +437 -0
  90. coder_eval/orchestrator.py +2078 -0
  91. coder_eval/path_utils.py +79 -0
  92. coder_eval/plugins.py +81 -0
  93. coder_eval/pricing.py +169 -0
  94. coder_eval/py.typed +0 -0
  95. coder_eval/reports.py +1066 -0
  96. coder_eval/reports_experiment.py +761 -0
  97. coder_eval/reports_html.py +1659 -0
  98. coder_eval/reports_stats.py +250 -0
  99. coder_eval/resources/__init__.py +137 -0
  100. coder_eval/resources/default_experiment.yaml +85 -0
  101. coder_eval/resources/default_ignore_patterns.yaml +60 -0
  102. coder_eval/resources/tags.yaml +55 -0
  103. coder_eval/sandbox.py +1127 -0
  104. coder_eval/scoring/__init__.py +11 -0
  105. coder_eval/scoring/ast_similarity.py +38 -0
  106. coder_eval/scoring/complexity.py +115 -0
  107. coder_eval/scoring/quality.py +154 -0
  108. coder_eval/scoring/signature_similarity.py +57 -0
  109. coder_eval/scoring/similarity.py +103 -0
  110. coder_eval/scoring/token_similarity.py +44 -0
  111. coder_eval/simulation/__init__.py +27 -0
  112. coder_eval/simulation/termination.py +88 -0
  113. coder_eval/simulation/user_simulator.py +324 -0
  114. coder_eval/streaming/__init__.py +44 -0
  115. coder_eval/streaming/callbacks.py +57 -0
  116. coder_eval/streaming/collector.py +193 -0
  117. coder_eval/streaming/events.py +198 -0
  118. coder_eval/streaming/renderers.py +248 -0
  119. coder_eval/streaming/wire.py +115 -0
  120. coder_eval/telemetry.py +407 -0
  121. coder_eval/utils.py +517 -0
  122. coder_eval-0.8.2.dist-info/METADATA +242 -0
  123. coder_eval-0.8.2.dist-info/RECORD +127 -0
  124. coder_eval-0.8.2.dist-info/WHEEL +4 -0
  125. coder_eval-0.8.2.dist-info/entry_points.txt +5 -0
  126. coder_eval-0.8.2.dist-info/licenses/LICENSE +201 -0
  127. coder_eval-0.8.2.dist-info/licenses/NOTICE +18 -0
@@ -0,0 +1,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