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,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
|