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,111 @@
1
+ """Configuration models for orchestration."""
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from pydantic import BaseModel, ConfigDict, Field
7
+
8
+ from coder_eval.models import PreservationMode
9
+
10
+
11
+ def resolve_preservation_mode(explicit: PreservationMode | None, driver: str) -> PreservationMode:
12
+ """Resolve the effective preservation mode for a task.
13
+
14
+ An explicit ``--preservation-mode`` always wins. Otherwise the default is
15
+ driver-derived: ``docker`` → ``DIRECT_WRITE`` (isolated container; writing
16
+ straight to the bind-mounted artifacts dir avoids a cross-mount copy),
17
+ every other driver → ``MOVE_ON_WRITE`` (keeps the run off ``run_dir`` so
18
+ parent-dir ``node_modules`` can't contaminate Node tool resolution).
19
+
20
+ This must be called where the *original* driver is known (the dispatch seam
21
+ in ``batch.run_single``); the in-container orchestrator sees the driver
22
+ forced to ``tempdir`` and would otherwise mis-resolve docker → MOVE.
23
+ """
24
+ if explicit is not None:
25
+ return explicit
26
+ return PreservationMode.DIRECT_WRITE if driver == "docker" else PreservationMode.MOVE_ON_WRITE
27
+
28
+
29
+ class BatchRunConfig(BaseModel):
30
+ """Configuration for batch task execution.
31
+
32
+ This configuration object encapsulates all parameters needed to run
33
+ multiple tasks in batch mode with optional parallelism.
34
+ """
35
+
36
+ model_config = ConfigDict(extra="forbid")
37
+
38
+ run_dir: Path = Field(description="Directory for this batch run")
39
+ max_parallel: int = Field(default=1, ge=1, description="Max concurrent tasks")
40
+ preservation_mode: PreservationMode | None = Field(
41
+ default=None,
42
+ description=(
43
+ "How to persist each task's sandbox. None = driver-derived default "
44
+ "(docker → DIRECT_WRITE, else MOVE_ON_WRITE), resolved per-task at dispatch."
45
+ ),
46
+ )
47
+ include_tags: set[str] | None = Field(default=None, description="Only run tasks matching any of these tags")
48
+ exclude_tags: set[str] | None = Field(default=None, description="Skip tasks matching any of these tags")
49
+ include_skipped: bool = Field(
50
+ default=False,
51
+ description=(
52
+ "Run tasks marked `skip: true` in their YAML instead of quarantining them. "
53
+ "Off by default so the nightly/CI keep excluding skipped tasks; pass "
54
+ "--include-skipped for on-demand / local runs of quarantined or opt-in tasks."
55
+ ),
56
+ )
57
+
58
+ # Agent type override stays a dedicated field: it requires re-parsing the
59
+ # discriminated union (not a simple field-merge), so it is injected into the
60
+ # generic agent patch by apply_overrides rather than living in `overrides`.
61
+ agent_type: str | None = Field(default=None, description="Override agent type for all tasks (e.g., 'claude-code')")
62
+
63
+ # Generic layer-5 task-config overrides. Built from -D/--set and the surviving
64
+ # flag aliases (--model, --driver) in run_command, then applied to the resolved
65
+ # TaskDefinition by orchestration.overrides.
66
+ overrides: dict[str, Any] = Field(
67
+ default_factory=dict,
68
+ description=(
69
+ "Generic layer-5 task-config overrides (dotted path -> typed value) "
70
+ "from -D/--set and the surviving flag aliases (--model, --driver)."
71
+ ),
72
+ )
73
+
74
+ # Dataset sampling (for cheap smoke runs on dataset-backed tasks)
75
+ max_rows: int | None = Field(
76
+ default=None,
77
+ ge=1,
78
+ description="Cap rows per dataset-backed task to first N. Non-dataset tasks unaffected.",
79
+ )
80
+ sample_per_stratum: int | None = Field(
81
+ default=None,
82
+ ge=1,
83
+ description=(
84
+ "CLI override (--sample-per-stratum) for dataset.sample_per_stratum: keep up to N "
85
+ "rows per stratum (stratify_field, default expected_skill). Lets a runner cap a "
86
+ "stratified dataset without editing the task YAML. Ignored when max_rows is set."
87
+ ),
88
+ )
89
+
90
+ # Replicate count override
91
+ repeats: int | None = Field(
92
+ default=None,
93
+ ge=1,
94
+ description="CLI override for replicates per (task, variant). None = defer to experiment layers.",
95
+ )
96
+
97
+ # Logging
98
+ verbose: bool = Field(default=False, description="Enable verbose (DEBUG level) logging for Docker output")
99
+
100
+ # TODO(container-death-diagnostics): consider a run-level default resource
101
+ # cap. Containers run uncapped today (sandbox.limits.{max_memory_mb,
102
+ # max_cpus,max_pids} default to None -> _build_argv emits no --memory/
103
+ # --cpus/--pids-limit), so at --max-parallel=20 a single runaway task can
104
+ # pressure the whole host. An opt-in default cap is already expressible
105
+ # via the EXISTING layered sandbox config -- defaults.sandbox.limits.
106
+ # max_memory_mb in the experiment YAML, or `-D sandbox.limits.
107
+ # max_memory_mb=N` on `coder-eval run` -- both flow through
108
+ # resolve_all_tasks and are overridden by per-task limits. If a dedicated
109
+ # CLI knob is ever wanted, add it as the FIRST (lowest-priority) layer in
110
+ # _build_sandbox_layers so per-task limits win, and do NOT default it to
111
+ # a non-None value (would change behavior for existing configs).
@@ -0,0 +1,431 @@
1
+ """The single generic config-merge resolver.
2
+
3
+ One implementation that merges any of the three ``-D``-reachable root models
4
+ (``agent`` / ``run_limits`` / ``sandbox``) across an ordered list of partial-dict
5
+ :class:`Layer` records, applying each field's declared merge strategy
6
+ (:func:`coder_eval.models.merge_strategy.merge_strategy_of`) uniformly at every
7
+ layer. Both the layers-1-4 path (``resolve_task_for_variant``) and the layer-5
8
+ path (``apply_overrides``) call :func:`resolve_root`, so a given field merges
9
+ identically regardless of which layer supplied its value — that value-equality
10
+ is the unification invariant the refactor exists to guarantee.
11
+
12
+ The walk validates *paths* (unknown key -> :class:`MergeError` with a difflib
13
+ "did you mean?") and emits dotted lineage as a side effect; *value* validation
14
+ stays in Pydantic via reconstruction in :func:`resolve_root`.
15
+
16
+ Deliberately CLI-free (no ``typer`` import — lint rule CE004). The CLI boundary
17
+ wraps :class:`MergeError` into ``typer.BadParameter``.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import copy
23
+ import difflib
24
+ from collections.abc import Mapping, Sequence
25
+ from dataclasses import dataclass
26
+ from typing import Any, Literal, overload
27
+
28
+ from pydantic import BaseModel
29
+
30
+ from ..models import (
31
+ BaseAgentConfig,
32
+ ClaudeCodeAgentConfig,
33
+ CodexAgentConfig,
34
+ ConfigLineageEntry,
35
+ RunLimits,
36
+ SandboxConfig,
37
+ TaskDefinition,
38
+ parse_agent_config,
39
+ )
40
+ from ..models.merge_strategy import AppendOrder, MergeStrategy, append_order_of, classify_annotation, merge_strategy_of
41
+
42
+
43
+ # Layer sources, ordered low -> high precedence. A subset of
44
+ # ``ConfigLineageEntry.source`` (which also carries "mutation"); kept in sync by
45
+ # tests/test_config_merge_engine.py::test_config_source_subset_of_lineage_source.
46
+ ConfigSource = Literal[
47
+ "default",
48
+ "experiment-defaults",
49
+ "task",
50
+ "variant",
51
+ "cli",
52
+ ]
53
+
54
+ ALLOWED_OVERRIDE_ROOTS: tuple[str, ...] = ("agent", "run_limits", "sandbox")
55
+
56
+ RootName = Literal["agent", "run_limits", "sandbox"]
57
+
58
+
59
+ class MergeError(ValueError):
60
+ """Raised for an unknown ``-D`` path / nested key during the merge walk.
61
+
62
+ Carries an optional ``suggestion`` (the closest known field name) so the CLI
63
+ boundary can surface a "did you mean?" hint.
64
+ """
65
+
66
+ def __init__(self, message: str, *, suggestion: str | None = None) -> None:
67
+ super().__init__(message)
68
+ self.suggestion = suggestion
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class Layer:
73
+ """One precedence layer fed to :func:`merge_layers`.
74
+
75
+ ``patch`` is a partial nested dict (e.g. ``model_dump(exclude_unset=True)``
76
+ or a ``-D``-built patch). ``detail`` is the ``source_detail`` recorded in
77
+ lineage: a single string applied to every key this layer sets, OR a mapping
78
+ of dotted path (relative to the root, e.g. ``"docker.network"``) to detail
79
+ for per-key provenance (e.g. the ``--type`` alias records ``"agent.type":
80
+ "--type"``). When ``detail`` is None and ``source == "cli"``, the detail
81
+ auto-derives as ``"-D <root>.<path>"``.
82
+ ``record_lineage=False`` (the layer-5 value seed) contributes values only and
83
+ writes no lineage, so lower layers' provenance for untouched keys survives.
84
+ """
85
+
86
+ source: ConfigSource
87
+ patch: Mapping[str, Any]
88
+ detail: str | Mapping[str, str] | None = None
89
+ record_lineage: bool = True
90
+
91
+
92
+ # ---------------------------------------------------------------------------
93
+ # Root model-type derivation (programmatic — no hardcoded union tuple)
94
+ # ---------------------------------------------------------------------------
95
+
96
+
97
+ def _root_model_types(root: str) -> tuple[type[BaseModel], ...]:
98
+ """Concrete model types a root resolves to.
99
+
100
+ For ``agent`` this is every *registered* agent config class (built-in or
101
+ plugin) plus ``BaseAgentConfig`` (so subclass-only fields like ``sdk_options``
102
+ and plugin fields are recognized for ``-D`` validation / did-you-mean) — read
103
+ from the registry after plugin load, not a static annotation. ``run_limits`` /
104
+ ``sandbox`` derive from ``TaskDefinition`` via :func:`classify_annotation`.
105
+ Raises :class:`MergeError` for an unknown root.
106
+ """
107
+ if root not in ALLOWED_OVERRIDE_ROOTS:
108
+ raise MergeError(f"unknown override root {root!r}; allowed roots: {', '.join(ALLOWED_OVERRIDE_ROOTS)}")
109
+ if root == "agent":
110
+ from coder_eval.agents.registry import AgentRegistry
111
+ from coder_eval.plugins import ensure_plugins_loaded
112
+
113
+ ensure_plugins_loaded()
114
+ models = [reg.config_class for reg in AgentRegistry.registrations()]
115
+ models.append(BaseAgentConfig)
116
+ else:
117
+ annotation = TaskDefinition.model_fields[root].annotation
118
+ # Reuse the shared walker; we only need its nested-model list here and
119
+ # discard the free-form-dict flag.
120
+ models, _is_free_form_dict = classify_annotation(annotation)
121
+ # Dedupe preserving order.
122
+ seen: set[type[BaseModel]] = set()
123
+ deduped = [m for m in models if not (m in seen or seen.add(m))]
124
+ return tuple(deduped)
125
+
126
+
127
+ def _exclusion_groups(model_types: Sequence[type[BaseModel]]) -> tuple[tuple[str, ...], ...]:
128
+ """Union of every member's ``_merge_exclusive_groups`` class attribute."""
129
+ groups: list[tuple[str, ...]] = []
130
+ for m in model_types:
131
+ for g in getattr(m, "_merge_exclusive_groups", ()):
132
+ if g not in groups:
133
+ groups.append(tuple(g))
134
+ return tuple(groups)
135
+
136
+
137
+ def _unknown_key_error(segment: str, model_types: Sequence[type[BaseModel]], context: str) -> MergeError:
138
+ known = sorted({name for m in model_types for name in m.model_fields})
139
+ suggestion = difflib.get_close_matches(segment, known, n=1)
140
+ hint = f"; did you mean {suggestion[0]!r}?" if suggestion else ""
141
+ return MergeError(
142
+ f"unknown field {segment!r} under {context!r}{hint}", suggestion=suggestion[0] if suggestion else None
143
+ )
144
+
145
+
146
+ # ---------------------------------------------------------------------------
147
+ # Value merge (recursive; honors per-field strategies; no lineage)
148
+ # ---------------------------------------------------------------------------
149
+
150
+
151
+ def _deep_dict_merge(base: dict[str, Any], patch: Mapping[str, Any]) -> dict[str, Any]:
152
+ """Recursive dict merge: nested dicts merge; every other value (incl. lists)
153
+ replaces. Shares NO structure with either input — nested dicts are merged into
154
+ fresh dicts and leaf values (lists/scalars/etc.) are deep-copied — so the result
155
+ can be mutated without leaking back into a patch reused across rows/layers
156
+ (e.g. a variant's ``sdk_options`` dict reused for every dataset row)."""
157
+ result: dict[str, Any] = {
158
+ k: (_deep_dict_merge(v, {}) if isinstance(v, dict) else copy.deepcopy(v)) for k, v in base.items()
159
+ }
160
+ for key, value in patch.items():
161
+ existing = result.get(key)
162
+ if isinstance(existing, dict) and isinstance(value, dict):
163
+ result[key] = _deep_dict_merge(existing, value)
164
+ else:
165
+ result[key] = copy.deepcopy(value)
166
+ return result
167
+
168
+
169
+ def _merge_value(
170
+ strategy: MergeStrategy,
171
+ annotation: Any,
172
+ key: str,
173
+ base_val: Any,
174
+ new_val: Any,
175
+ context: str,
176
+ append_order: AppendOrder = "forward",
177
+ ) -> Any:
178
+ """Merge one field's value per its ``strategy``. Validates nested keys (for
179
+ deep-model fields). Never mutates inputs. Emits no lineage."""
180
+ if strategy == "append":
181
+ if new_val is None:
182
+ # An Optional append-list left unset at this layer (e.g. a full-dump
183
+ # seed dumping ``template_sources: None``) contributes nothing.
184
+ return base_val
185
+ if not isinstance(new_val, list):
186
+ raise MergeError(
187
+ f"expected a list for append-strategy field {context}.{key!r}, got {type(new_val).__name__}"
188
+ )
189
+ existing = base_val if isinstance(base_val, list) else []
190
+ # ``reverse`` puts this (higher) layer's items first — e.g. post_run, where
191
+ # the task's commands precede experiment-defaults cleanup.
192
+ if append_order == "reverse":
193
+ return list(new_val) + list(existing)
194
+ return list(existing) + list(new_val)
195
+
196
+ if strategy == "deep":
197
+ child_models, _is_free_form_dict = classify_annotation(annotation)
198
+ if child_models:
199
+ if not isinstance(new_val, dict):
200
+ return new_val # wholesale replace of a nested model with a non-dict (unusual)
201
+ base_dict = base_val if isinstance(base_val, dict) else {}
202
+ return _merge_dict_by_model(base_dict, new_val, tuple(child_models), f"{context}.{key}")
203
+ # free-form dict (or an explicit ``deep`` on a plain dict)
204
+ if isinstance(new_val, dict):
205
+ base_dict = base_val if isinstance(base_val, dict) else {}
206
+ return _deep_dict_merge(base_dict, new_val)
207
+ return new_val # non-dict (e.g. claude_settings as a str / None) replaces
208
+
209
+ return new_val # replace
210
+
211
+
212
+ def _merge_dict_by_model(
213
+ base: Mapping[str, Any],
214
+ patch: Mapping[str, Any],
215
+ model_types: tuple[type[BaseModel], ...],
216
+ context: str,
217
+ ) -> dict[str, Any]:
218
+ """Field-by-field merge of two nested-model dicts honoring each field's
219
+ strategy. Validates every patch key against ``model_types``."""
220
+ result: dict[str, Any] = dict(base)
221
+ for key, value in patch.items():
222
+ matching = [m for m in model_types if key in m.model_fields]
223
+ if not matching:
224
+ raise _unknown_key_error(key, model_types, context)
225
+ field_info = matching[0].model_fields[key]
226
+ strategy = merge_strategy_of(field_info)
227
+ result[key] = _merge_value(
228
+ strategy, field_info.annotation, key, result.get(key), value, context, append_order_of(field_info)
229
+ )
230
+ return result
231
+
232
+
233
+ # ---------------------------------------------------------------------------
234
+ # Lineage emission
235
+ # ---------------------------------------------------------------------------
236
+
237
+
238
+ def _detail_for(layer: Layer, full_path: str) -> str | None:
239
+ """Resolve the ``source_detail`` for a dotted path under this layer."""
240
+ d = layer.detail
241
+ if isinstance(d, Mapping):
242
+ mapped = d.get(full_path)
243
+ if mapped is not None:
244
+ return mapped
245
+ elif d is not None:
246
+ return d
247
+ if layer.source == "cli":
248
+ return f"-D {full_path}"
249
+ return None
250
+
251
+
252
+ def _record_lineage(
253
+ lineage: dict[str, ConfigLineageEntry],
254
+ root: str,
255
+ key: str,
256
+ strategy: MergeStrategy,
257
+ annotation: Any,
258
+ layer_value: Any,
259
+ merged_value: Any,
260
+ layer: Layer,
261
+ ) -> None:
262
+ """Write dotted lineage for one field a layer just set.
263
+
264
+ Granularity: ``replace``/``append`` -> one entry at ``root.key``; ``deep-dict``
265
+ -> one entry per top-level sub-key the layer set (parity with the old sdk
266
+ handling); ``deep-model`` -> one coarse entry at ``root.key`` (the
267
+ highest-precedence layer that touched the subtree)."""
268
+ if strategy == "deep":
269
+ child_models, _is_free_form_dict = classify_annotation(annotation)
270
+ if not child_models and isinstance(layer_value, dict):
271
+ # deep-dict: per-top-level-leaf provenance.
272
+ for subkey, subval in layer_value.items():
273
+ path = f"{key}.{subkey}"
274
+ lineage[f"{root}.{path}"] = ConfigLineageEntry(
275
+ value=subval, source=layer.source, source_detail=_detail_for(layer, f"{root}.{path}")
276
+ )
277
+ return
278
+ # deep-model (or non-dict deep value): one coarse entry at the field path.
279
+ lineage[f"{root}.{key}"] = ConfigLineageEntry(
280
+ value=merged_value, source=layer.source, source_detail=_detail_for(layer, f"{root}.{key}")
281
+ )
282
+ return
283
+ # replace -> the layer's own value; append -> the merged list.
284
+ recorded = merged_value if strategy == "append" else layer_value
285
+ lineage[f"{root}.{key}"] = ConfigLineageEntry(
286
+ value=recorded, source=layer.source, source_detail=_detail_for(layer, f"{root}.{key}")
287
+ )
288
+
289
+
290
+ # ---------------------------------------------------------------------------
291
+ # The generic resolver
292
+ # ---------------------------------------------------------------------------
293
+
294
+
295
+ def merge_layers(
296
+ model_types: tuple[type[BaseModel], ...],
297
+ layers: Sequence[Layer],
298
+ *,
299
+ lineage_root: str,
300
+ lineage: dict[str, ConfigLineageEntry] | None = None,
301
+ ) -> dict[str, Any]:
302
+ """Walk ``model_fields``, apply each field's strategy across ``layers`` in
303
+ order, and (when ``lineage`` is given) record dotted lineage as a side
304
+ effect. Returns the merged nested dict for the root's constructor — does NOT
305
+ construct (the caller reconstructs so Pydantic re-validates values).
306
+
307
+ Raises :class:`MergeError` on an unknown key (with a did-you-mean suggestion).
308
+ Inputs are never mutated.
309
+ """
310
+ accum: dict[str, Any] = {}
311
+ groups = _exclusion_groups(model_types)
312
+ for layer in layers:
313
+ # Exclusion groups: when a layer sets any group member, drop the OTHER
314
+ # members (and their lineage) before writing — so a record never
315
+ # advertises a sibling the resolved model no longer carries.
316
+ for group in groups:
317
+ set_members = {m for m in group if layer.patch.get(m) is not None}
318
+ if set_members:
319
+ for other in group:
320
+ if other not in set_members:
321
+ accum.pop(other, None)
322
+ # A lineage-silent seed (record_lineage=False) clears values
323
+ # but must not touch lineage — the layers-1-4 provenance is
324
+ # authoritative for fields it didn't introduce.
325
+ if lineage is not None and layer.record_lineage:
326
+ # Drop the coarse entry AND any per-leaf entries (a deep
327
+ # field's lineage lives under `root.other.<subkey>`), so a
328
+ # record never advertises a cleared sibling.
329
+ prefix = f"{lineage_root}.{other}"
330
+ for k in [k for k in lineage if k == prefix or k.startswith(prefix + ".")]:
331
+ lineage.pop(k, None)
332
+ for key, value in layer.patch.items():
333
+ matching = [m for m in model_types if key in m.model_fields]
334
+ if not matching:
335
+ raise _unknown_key_error(key, model_types, lineage_root)
336
+ field_info = matching[0].model_fields[key]
337
+ strategy = merge_strategy_of(field_info)
338
+ accum[key] = _merge_value(
339
+ strategy, field_info.annotation, key, accum.get(key), value, lineage_root, append_order_of(field_info)
340
+ )
341
+ if lineage is not None and layer.record_lineage:
342
+ _record_lineage(lineage, lineage_root, key, strategy, field_info.annotation, value, accum[key], layer)
343
+ return accum
344
+
345
+
346
+ @overload
347
+ def resolve_root(
348
+ root: Literal["agent"], layers: Sequence[Layer], *, lineage: dict[str, ConfigLineageEntry] | None = ...
349
+ ) -> ClaudeCodeAgentConfig | CodexAgentConfig | BaseAgentConfig | None:
350
+ """Resolve the ``agent`` root to its concrete agent-config model."""
351
+
352
+
353
+ @overload
354
+ def resolve_root(
355
+ root: Literal["run_limits"], layers: Sequence[Layer], *, lineage: dict[str, ConfigLineageEntry] | None = ...
356
+ ) -> RunLimits | None:
357
+ """Resolve the ``run_limits`` root (None when no layer set anything)."""
358
+
359
+
360
+ @overload
361
+ def resolve_root(
362
+ root: Literal["sandbox"], layers: Sequence[Layer], *, lineage: dict[str, ConfigLineageEntry] | None = ...
363
+ ) -> SandboxConfig | None:
364
+ """Resolve the ``sandbox`` root (None when no layer set anything)."""
365
+
366
+
367
+ def resolve_root(
368
+ root: RootName,
369
+ layers: Sequence[Layer],
370
+ *,
371
+ lineage: dict[str, ConfigLineageEntry] | None = None,
372
+ ) -> BaseModel | None:
373
+ """:func:`merge_layers` + reconstruct via the root's constructor.
374
+
375
+ The single merge-and-build entry point both resolution paths call. Value
376
+ validation (``extra="forbid"``, field validators, SandboxConfig's
377
+ template-sources model_validator) happens in the constructor. Returns None
378
+ when no layer set anything for ``run_limits`` (an empty block is dropped)."""
379
+ model_types = _root_model_types(root) # raises MergeError for an unknown root
380
+ merged = merge_layers(model_types, layers, lineage_root=root, lineage=lineage)
381
+ if root == "agent":
382
+ return parse_agent_config(**merged)
383
+ if root == "run_limits":
384
+ return RunLimits(**merged) if merged else None
385
+ # root == "sandbox" — the only remaining RootName member.
386
+ return SandboxConfig(**merged) if merged else None
387
+
388
+
389
+ # ---------------------------------------------------------------------------
390
+ # Path validation (shared with the layer-5 ``-D`` engine)
391
+ # ---------------------------------------------------------------------------
392
+
393
+
394
+ def validate_paths(paths: Sequence[str]) -> None:
395
+ """Validate dotted ``-D`` paths against the root schemas (no values).
396
+
397
+ Walks each path segment-by-segment exactly as :func:`merge_layers` looks up
398
+ fields: unknown root / field -> :class:`MergeError` (with did-you-mean);
399
+ stops (accepting everything below) at a free-form ``dict`` boundary; rejects
400
+ extra segments past a scalar/list leaf.
401
+ """
402
+ for path in paths:
403
+ _validate_path(path)
404
+
405
+
406
+ def _validate_path(path: str) -> None:
407
+ segments = path.split(".")
408
+ root = segments[0]
409
+ model_types = _root_model_types(root) # raises MergeError on unknown root
410
+ rest = segments[1:]
411
+ if not rest:
412
+ raise MergeError(f"override path {path!r} must target a field under {root!r}, not the root itself")
413
+ context = root
414
+ for i, segment in enumerate(rest):
415
+ matching = [m for m in model_types if segment in m.model_fields]
416
+ if not matching:
417
+ raise _unknown_key_error(segment, model_types, context)
418
+ child_models: list[type[BaseModel]] = []
419
+ is_free_form_dict = False
420
+ for m in matching:
421
+ sub_models, sub_dict = classify_annotation(m.model_fields[segment].annotation)
422
+ child_models.extend(sub_models)
423
+ is_free_form_dict = is_free_form_dict or sub_dict
424
+ if is_free_form_dict:
425
+ return # free-form dict boundary — accept everything below
426
+ if i == len(rest) - 1:
427
+ return # valid scalar / list / model leaf
428
+ if not child_models:
429
+ raise MergeError(f"cannot descend into {segment!r} under {context!r}: it is a scalar or list leaf")
430
+ model_types = tuple(child_models)
431
+ context = f"{context}.{segment}"
@@ -0,0 +1,94 @@
1
+ """Reference-loading helpers for the orchestrator.
2
+
3
+ Loads the reference solution (code / file / directory form) consumed by the
4
+ ``reference_comparison``, ``llm_judge``, and ``agent_judge`` criteria.
5
+ """
6
+
7
+ import logging
8
+ from pathlib import Path
9
+
10
+ from ..models import TaskDefinition
11
+
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ def load_reference(
17
+ task: TaskDefinition,
18
+ task_file: Path | None,
19
+ cached_reference: str | None,
20
+ ) -> tuple[str | None, Path | None, str | None]:
21
+ """Load reference solution from task definition.
22
+
23
+ Returns the reference in whichever form the task declared:
24
+ ``code`` / ``file`` produce a string ``reference_code``; ``directory``
25
+ produces a resolved ``reference_dir`` ``Path``. At most one is non-None
26
+ on any given call.
27
+
28
+ Args:
29
+ task: Task definition with reference configuration
30
+ task_file: Path to task YAML file (for resolving relative paths)
31
+ cached_reference: Previously loaded ``reference_code`` (for caching).
32
+ Directory paths are resolved fresh each call — path resolution
33
+ is cheap and the directory contents are read by the consumer.
34
+
35
+ Returns:
36
+ Tuple of (reference_code, reference_dir, cached_reference).
37
+ ``cached_reference`` should be stored for future calls (string forms only).
38
+
39
+ Raises:
40
+ FileNotFoundError: if the reference file or directory doesn't exist.
41
+ ValueError: if ``task_file`` is not provided when needed for path resolution.
42
+
43
+ Security: the reference is NEVER shown to the agent. It is only consumed
44
+ by ``llm_judge`` / ``agent_judge`` and ``reference_comparison`` criteria.
45
+ """
46
+ # String-form cache short-circuit. Directory form is resolved fresh below
47
+ # because Path resolution is nanoseconds and directory content varies.
48
+ if cached_reference is not None:
49
+ return cached_reference, None, cached_reference
50
+
51
+ if not task.reference:
52
+ return None, None, None
53
+
54
+ reference_code: str | None = None
55
+ reference_dir: Path | None = None
56
+
57
+ if task.reference.code:
58
+ reference_code = task.reference.code
59
+ elif task.reference.file:
60
+ if not task_file:
61
+ raise ValueError("task_file not set, cannot resolve reference file path")
62
+ ref_path = task_file.parent / task.reference.file
63
+ if not ref_path.exists():
64
+ raise FileNotFoundError(f"Reference file not found: {ref_path} (specified in {task_file})")
65
+ reference_code = ref_path.read_text(encoding="utf-8")
66
+ elif task.reference.directory:
67
+ if not task_file:
68
+ raise ValueError("task_file not set, cannot resolve reference directory path")
69
+ ref_dir = (task_file.parent / task.reference.directory).resolve()
70
+ if not ref_dir.is_dir():
71
+ raise FileNotFoundError(f"Reference directory not found: {ref_dir} (specified in {task_file})")
72
+ reference_dir = ref_dir
73
+
74
+ # Log that reference was loaded (but NOT the content for security)
75
+ logger.info("Reference solution loaded (content hidden for security)")
76
+ return reference_code, reference_dir, reference_code
77
+
78
+
79
+ # Backward-compat alias — orchestrator.py still imports this name in places we
80
+ # didn't yet update. New code should use ``load_reference``.
81
+ def load_reference_code(
82
+ task: TaskDefinition,
83
+ task_file: Path | None,
84
+ cached_reference: str | None,
85
+ ) -> tuple[str | None, str | None]:
86
+ """Compatibility wrapper around ``load_reference`` returning the legacy two-tuple.
87
+
88
+ Use ``load_reference`` directly when you also need the directory path
89
+ (i.e. anywhere agent_judge can run). Kept so existing call sites that
90
+ only consume the string form (``reference_comparison``,
91
+ pre-directory-feature paths) don't have to change.
92
+ """
93
+ code, _dir, cache = load_reference(task=task, task_file=task_file, cached_reference=cached_reference)
94
+ return code, cache