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,291 @@
|
|
|
1
|
+
"""Sibling-file persistence for ``JudgeCriterionResult.transcript``.
|
|
2
|
+
|
|
3
|
+
The full judge transcript (tool calls, raw verdict, rendered prompt and
|
|
4
|
+
system prompt) can run 10-100 KB. Inlining it into every ``task.json``
|
|
5
|
+
inflates the row record for consumers (suite rollups, report renderers)
|
|
6
|
+
that don't need it. Spilling each transcript to a sibling
|
|
7
|
+
``judge-<idx>.yaml`` next to ``task.json`` keeps the row record lean and
|
|
8
|
+
lets reviewers grep transcripts independently.
|
|
9
|
+
|
|
10
|
+
YAML (over JSON) for the sibling: the transcript carries multi-line text
|
|
11
|
+
(``judge_prompt``, ``judge_system_prompt``, ``raw_verdict``) which YAML's
|
|
12
|
+
literal block scalar (``|``) renders as readable paragraphs instead of
|
|
13
|
+
single-line strings with ``\\n`` escapes. Sibling consumers are humans;
|
|
14
|
+
YAML wins.
|
|
15
|
+
|
|
16
|
+
Two functions:
|
|
17
|
+
|
|
18
|
+
- ``spill_judge_transcripts``: called by the orchestrator just before it
|
|
19
|
+
writes ``task.json``. For each judge result with an inline ``transcript``,
|
|
20
|
+
writes a sibling file and sets ``transcript_path`` on the result. The
|
|
21
|
+
inline ``transcript`` is left in place so HTML rendering against the
|
|
22
|
+
in-memory ``EvaluationResult`` still sees it; ``model_dump_json``
|
|
23
|
+
callers strip it via ``exclude={...}``.
|
|
24
|
+
|
|
25
|
+
- ``load_judge_transcripts``: called by re-render paths
|
|
26
|
+
(``coder-eval report``, stats loaders) after
|
|
27
|
+
``EvaluationResult.model_validate_json``. For each result whose
|
|
28
|
+
``transcript_path`` points at an existing sibling file, reads it back
|
|
29
|
+
and attaches it as a dict on ``transcript`` so renderers see the same
|
|
30
|
+
shape they get during the original run. Accepts both ``.yaml`` (the
|
|
31
|
+
current format) and ``.json`` (the previous format) so previously-spilled
|
|
32
|
+
runs keep rendering.
|
|
33
|
+
|
|
34
|
+
Backward compatibility: old ``task.json`` files with inline ``transcript``
|
|
35
|
+
keep working — the loader treats ``transcript_path is None`` as a no-op.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import json
|
|
41
|
+
import logging
|
|
42
|
+
from pathlib import PurePosixPath, PureWindowsPath
|
|
43
|
+
from typing import TYPE_CHECKING, Any
|
|
44
|
+
|
|
45
|
+
import yaml
|
|
46
|
+
from pydantic import ValidationError
|
|
47
|
+
|
|
48
|
+
from coder_eval.models import JudgeCriterionResult, JudgeTranscript
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
if TYPE_CHECKING:
|
|
52
|
+
from pathlib import Path
|
|
53
|
+
|
|
54
|
+
from coder_eval.models import EvaluationResult
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
logger = logging.getLogger(__name__)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# Windows reserved device basenames. The Win32 API maps these to character
|
|
61
|
+
# devices regardless of the directory they sit in — opening ``CON`` or ``NUL.yaml``
|
|
62
|
+
# inside ``task_dir`` resolves to the console or the null device, not a file.
|
|
63
|
+
# Case-insensitive; the trailing extension (if any) is ignored by Win32 too,
|
|
64
|
+
# so ``con``, ``CON``, ``CON.yaml``, ``nul.txt`` all map to devices.
|
|
65
|
+
# Only COM1-9 / LPT1-9 are device names; COM10 and beyond are regular files.
|
|
66
|
+
_WINDOWS_RESERVED_BASENAMES = frozenset(
|
|
67
|
+
{"CON", "PRN", "AUX", "NUL"} | {f"COM{i}" for i in range(1, 10)} | {f"LPT{i}" for i in range(1, 10)}
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# Field order in the YAML output: lead with the human-readable summary fields
|
|
72
|
+
# (durations, token counts, prompts), then the long body fields. Verdict-shape
|
|
73
|
+
# (raw_verdict) goes last because it's the bulkiest.
|
|
74
|
+
_TRANSCRIPT_FIELD_ORDER = (
|
|
75
|
+
"duration_seconds",
|
|
76
|
+
"truncated",
|
|
77
|
+
"token_usage",
|
|
78
|
+
"tool_calls",
|
|
79
|
+
"judge_system_prompt",
|
|
80
|
+
"judge_prompt",
|
|
81
|
+
"raw_verdict",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class _BlockLiteralDumper(yaml.SafeDumper):
|
|
86
|
+
"""SafeDumper that renders multi-line strings as literal block scalars.
|
|
87
|
+
|
|
88
|
+
Without the override, pyyaml dumps multi-line strings as quoted single-line
|
|
89
|
+
strings with ``\\n`` escapes — unreadable for the rendered prompts and raw
|
|
90
|
+
verdicts that are the whole point of this file. The override flips strings
|
|
91
|
+
containing newlines to use the ``|`` style (literal block scalar) so they
|
|
92
|
+
render as native indented paragraphs.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _str_presenter(dumper: _BlockLiteralDumper, data: str) -> Any:
|
|
97
|
+
if "\n" in data:
|
|
98
|
+
return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="|")
|
|
99
|
+
return dumper.represent_scalar("tag:yaml.org,2002:str", data)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
_BlockLiteralDumper.add_representer(str, _str_presenter)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _ordered_transcript_dict(transcript_dump: dict[str, Any]) -> dict[str, Any]:
|
|
106
|
+
"""Return ``transcript_dump`` with keys in the human-friendly order above.
|
|
107
|
+
|
|
108
|
+
Unknown keys (forward-compat) are appended after the known ones in their
|
|
109
|
+
original insertion order.
|
|
110
|
+
"""
|
|
111
|
+
ordered: dict[str, Any] = {}
|
|
112
|
+
for key in _TRANSCRIPT_FIELD_ORDER:
|
|
113
|
+
if key in transcript_dump:
|
|
114
|
+
ordered[key] = transcript_dump[key]
|
|
115
|
+
for key, value in transcript_dump.items():
|
|
116
|
+
if key not in ordered:
|
|
117
|
+
ordered[key] = value
|
|
118
|
+
return ordered
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def spill_judge_transcripts(result: EvaluationResult, output_dir: Path) -> int:
|
|
122
|
+
"""Write each judge result's inline transcript to a sibling YAML file.
|
|
123
|
+
|
|
124
|
+
For each ``JudgeCriterionResult`` in ``result.success_criteria_results``
|
|
125
|
+
that carries a non-None ``transcript``, writes ``judge-<idx>.yaml`` in
|
|
126
|
+
``output_dir`` (creating the directory if needed) and sets
|
|
127
|
+
``transcript_path`` on the result to the sibling filename.
|
|
128
|
+
|
|
129
|
+
The inline ``transcript`` is **left in place** so in-memory consumers
|
|
130
|
+
(HTML rendering at the end of the orchestrator run) still see it.
|
|
131
|
+
Callers writing ``task.json`` should pass
|
|
132
|
+
``exclude={"success_criteria_results": {"__all__": {"transcript"}}}``
|
|
133
|
+
to ``model_dump_json`` so the on-disk record carries only the path.
|
|
134
|
+
|
|
135
|
+
Returns the count of transcripts spilled (informational; no-op when 0).
|
|
136
|
+
"""
|
|
137
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
138
|
+
spilled = 0
|
|
139
|
+
# ORDER IS LOAD-BEARING. ``judge-{idx}.yaml`` is keyed off the criterion's
|
|
140
|
+
# position in ``success_criteria_results``; ``load_judge_transcripts`` reads
|
|
141
|
+
# ``transcript_path`` (which we set below) to find each sibling, so the
|
|
142
|
+
# filename naming scheme itself can change freely. What MUST stay stable is
|
|
143
|
+
# the index→file mapping for the lifetime of any task.json that references
|
|
144
|
+
# these siblings: writers that reorder ``success_criteria_results`` between
|
|
145
|
+
# spill and read would break the binding. Today's only writer is the
|
|
146
|
+
# orchestrator and the order is preserved through model_dump_json/
|
|
147
|
+
# model_validate_json, so this is safe — keep it that way.
|
|
148
|
+
for idx, cr in enumerate(result.success_criteria_results):
|
|
149
|
+
if not isinstance(cr, JudgeCriterionResult):
|
|
150
|
+
continue
|
|
151
|
+
if cr.transcript is None:
|
|
152
|
+
continue
|
|
153
|
+
sibling_name = f"judge-{idx}.yaml"
|
|
154
|
+
sibling_path = output_dir / sibling_name
|
|
155
|
+
ordered = _ordered_transcript_dict(cr.transcript.model_dump())
|
|
156
|
+
sibling_path.write_text(
|
|
157
|
+
yaml.dump(
|
|
158
|
+
ordered,
|
|
159
|
+
Dumper=_BlockLiteralDumper,
|
|
160
|
+
sort_keys=False,
|
|
161
|
+
allow_unicode=True,
|
|
162
|
+
width=100,
|
|
163
|
+
),
|
|
164
|
+
encoding="utf-8",
|
|
165
|
+
)
|
|
166
|
+
cr.transcript_path = sibling_name
|
|
167
|
+
spilled += 1
|
|
168
|
+
if spilled:
|
|
169
|
+
logger.debug("spilled %d judge transcript(s) to %s", spilled, output_dir)
|
|
170
|
+
return spilled
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def load_judge_transcripts(result: EvaluationResult, task_dir: Path) -> int:
|
|
174
|
+
"""Read sibling judge transcript files and attach them to each result.
|
|
175
|
+
|
|
176
|
+
For each criterion result in ``result.success_criteria_results`` that has
|
|
177
|
+
a ``transcript_path`` set (and no inline ``transcript`` — already-loaded
|
|
178
|
+
results are left alone), reads the sibling file relative to ``task_dir``
|
|
179
|
+
and attaches the parsed dict on ``transcript`` so HTML / markdown
|
|
180
|
+
renderers see the same shape they get during the original run.
|
|
181
|
+
|
|
182
|
+
Missing sibling files are skipped silently and logged at debug level —
|
|
183
|
+
runs predating this feature have no sibling files and render fine via
|
|
184
|
+
their inline transcript (preserved through ``CriterionResult``'s
|
|
185
|
+
``extra="allow"`` round-trip).
|
|
186
|
+
|
|
187
|
+
Returns the count of transcripts loaded.
|
|
188
|
+
"""
|
|
189
|
+
loaded = 0
|
|
190
|
+
for cr in result.success_criteria_results:
|
|
191
|
+
path = getattr(cr, "transcript_path", None)
|
|
192
|
+
if not path:
|
|
193
|
+
continue
|
|
194
|
+
# Skip results that already have an inline transcript — typical for
|
|
195
|
+
# the orchestrator's own first HTML render, which runs against the
|
|
196
|
+
# in-memory result before it gets dumped + reloaded.
|
|
197
|
+
if getattr(cr, "transcript", None):
|
|
198
|
+
continue
|
|
199
|
+
# SECURITY: transcript_path comes from task.json, which may travel across
|
|
200
|
+
# trust boundaries (CI artifacts, shared eval bundles). spill_judge_transcripts
|
|
201
|
+
# only ever writes the literal ``f"judge-{idx}.yaml"`` — a basename, no
|
|
202
|
+
# separators, no ``..``. Allowlist the basename shape directly so a tampered
|
|
203
|
+
# ``transcript_path: '/etc/passwd'`` or ``../../secrets`` is refused at the
|
|
204
|
+
# door rather than relying on ``is_relative_to`` to catch it after a join.
|
|
205
|
+
# Check BOTH PurePosixPath (forward-slash separator) AND PureWindowsPath
|
|
206
|
+
# (forward and back-slash separators, drive-letter prefixes): a path like
|
|
207
|
+
# ``subdir\judge-0.yaml`` passes the POSIX check on Linux (backslash is a
|
|
208
|
+
# regular char) but resolves to a nested file on Windows. Rejecting under
|
|
209
|
+
# either interpretation enforces the basename-only policy regardless of
|
|
210
|
+
# which platform the task.json travels to next.
|
|
211
|
+
if PurePosixPath(path).name != path or PureWindowsPath(path).name != path:
|
|
212
|
+
logger.warning("Refusing to load judge transcript with non-basename path: %s", path)
|
|
213
|
+
continue
|
|
214
|
+
# Reject Windows reserved device basenames. On Windows, ``CON.yaml`` /
|
|
215
|
+
# ``NUL`` / ``COM1`` open the console / null device / serial port
|
|
216
|
+
# regardless of where they sit in the directory tree. The check is
|
|
217
|
+
# platform-independent so a task.json minted on Linux that ships such a
|
|
218
|
+
# transcript_path is rejected before it travels to Windows.
|
|
219
|
+
stem_upper = path.split(".", 1)[0].upper()
|
|
220
|
+
if stem_upper in _WINDOWS_RESERVED_BASENAMES:
|
|
221
|
+
logger.warning("Refusing to load judge transcript with reserved Windows device name: %s", path)
|
|
222
|
+
continue
|
|
223
|
+
sibling = task_dir / path
|
|
224
|
+
# Defense-in-depth: even with the basename guard above, resolve and verify
|
|
225
|
+
# containment before reading — symlinks inside ``task_dir`` could redirect
|
|
226
|
+
# outside it (a malicious bundle could ship one).
|
|
227
|
+
try:
|
|
228
|
+
resolved_sibling = sibling.resolve()
|
|
229
|
+
resolved_root = task_dir.resolve()
|
|
230
|
+
except OSError as e:
|
|
231
|
+
logger.warning("Failed to resolve judge transcript path %s: %s", sibling, e)
|
|
232
|
+
continue
|
|
233
|
+
if not resolved_sibling.is_relative_to(resolved_root):
|
|
234
|
+
logger.warning(
|
|
235
|
+
"Refusing to read judge transcript outside task dir: path=%s resolved=%s task_dir=%s",
|
|
236
|
+
path,
|
|
237
|
+
resolved_sibling,
|
|
238
|
+
resolved_root,
|
|
239
|
+
)
|
|
240
|
+
continue
|
|
241
|
+
if not resolved_sibling.is_file():
|
|
242
|
+
logger.debug("judge transcript path %s set but %s missing — skipping", path, resolved_sibling)
|
|
243
|
+
continue
|
|
244
|
+
sibling = resolved_sibling
|
|
245
|
+
try:
|
|
246
|
+
text = sibling.read_text(encoding="utf-8")
|
|
247
|
+
# Parse as JSON for legacy ``.json`` siblings; anything else (today's
|
|
248
|
+
# ``.yaml`` and any future format yaml.safe_load handles) goes through
|
|
249
|
+
# PyYAML, which also accepts JSON as a subset.
|
|
250
|
+
data = json.loads(text) if path.endswith(".json") else yaml.safe_load(text)
|
|
251
|
+
except Exception as e:
|
|
252
|
+
logger.warning("Failed to read judge transcript %s: %s", sibling, e)
|
|
253
|
+
continue
|
|
254
|
+
if not isinstance(data, dict):
|
|
255
|
+
# A scalar / list / None payload would silently land on the result and
|
|
256
|
+
# crash the HTML renderer (which assumes dict-or-typed) with an
|
|
257
|
+
# AttributeError on the first ``.get()``. Reject early.
|
|
258
|
+
logger.warning(
|
|
259
|
+
"Judge transcript %s is %s, expected mapping — skipping",
|
|
260
|
+
sibling,
|
|
261
|
+
type(data).__name__,
|
|
262
|
+
)
|
|
263
|
+
continue
|
|
264
|
+
# Prefer typed JudgeTranscript so renderer / aggregator code that does
|
|
265
|
+
# isinstance checks sees the same shape it gets during the original run.
|
|
266
|
+
# Fall back to the raw dict on ValidationError — older spilled siblings
|
|
267
|
+
# (pre-schema-change) or forward-compat keys shouldn't break re-render.
|
|
268
|
+
attached: JudgeTranscript | dict[str, Any]
|
|
269
|
+
try:
|
|
270
|
+
attached = JudgeTranscript.model_validate(data)
|
|
271
|
+
except ValidationError as e:
|
|
272
|
+
logger.debug("Judge transcript %s did not match JudgeTranscript schema, attaching as dict: %s", sibling, e)
|
|
273
|
+
attached = data
|
|
274
|
+
# Use object.__setattr__ so we don't go through pydantic's setter,
|
|
275
|
+
# which (depending on model_config of the loaded subclass) might
|
|
276
|
+
# validate or reject. The HTML renderer accepts both typed
|
|
277
|
+
# JudgeTranscript and dict-shape so either shape works downstream.
|
|
278
|
+
# NOTE: With the ``CriterionResultUnion`` discriminator on
|
|
279
|
+
# ``EvaluationResult.success_criteria_results``, ``cr`` is now a
|
|
280
|
+
# properly-typed ``JudgeCriterionResult`` after reload (not a base
|
|
281
|
+
# ``CriterionResult`` with the field in ``__pydantic_extra__``), so
|
|
282
|
+
# the assignment lands on the declared field directly.
|
|
283
|
+
try:
|
|
284
|
+
object.__setattr__(cr, "transcript", attached)
|
|
285
|
+
except Exception as e:
|
|
286
|
+
logger.warning("Failed to attach judge transcript onto %s: %s", type(cr).__name__, e)
|
|
287
|
+
continue
|
|
288
|
+
loaded += 1
|
|
289
|
+
if loaded:
|
|
290
|
+
logger.debug("loaded %d judge transcript(s) from %s", loaded, task_dir)
|
|
291
|
+
return loaded
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Usage extraction from judge LLM responses.
|
|
2
|
+
|
|
3
|
+
Kept separate from ``judge_models.py`` (whose docstring scopes it to pure
|
|
4
|
+
string utilities for model-name translation) — usage parsing is a distinct
|
|
5
|
+
concern. This helper turns a judge response into a ``TokenUsage`` so
|
|
6
|
+
``llm_judge`` can populate ``JudgeCriterionResult.token_usage`` uniformly
|
|
7
|
+
across the Anthropic / Bedrock-invoke (dict shape) backends.
|
|
8
|
+
|
|
9
|
+
Returns ``None`` (not a zero ``TokenUsage``) when usage is absent or empty,
|
|
10
|
+
so "unknown" stays distinguishable from "zero".
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from coder_eval.models import TokenUsage
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _coerce_int(value: Any) -> int:
|
|
21
|
+
"""Best-effort non-negative int coercion for a usage counter.
|
|
22
|
+
|
|
23
|
+
A malformed provider payload (non-numeric token value) must degrade to 0,
|
|
24
|
+
not fail the judge criterion — these extractors promise "usage or None,
|
|
25
|
+
never raise". ``int(value or 0)`` already handles falsy/missing; this also
|
|
26
|
+
absorbs genuinely non-coercible values.
|
|
27
|
+
"""
|
|
28
|
+
try:
|
|
29
|
+
return int(value or 0)
|
|
30
|
+
except (TypeError, ValueError):
|
|
31
|
+
return 0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def token_usage_from_anthropic_dict(resp: dict[str, Any]) -> TokenUsage | None:
|
|
35
|
+
"""Extract usage from an Anthropic / Bedrock-invoke Messages response dict.
|
|
36
|
+
|
|
37
|
+
Both ``invoke_anthropic_judge`` (``response.model_dump()``) and
|
|
38
|
+
``invoke_bedrock_judge`` (parsed ``/invoke`` JSON) carry an Anthropic-shaped
|
|
39
|
+
``usage`` block. Returns ``None`` when usage is missing or carries no tokens.
|
|
40
|
+
"""
|
|
41
|
+
u = resp.get("usage")
|
|
42
|
+
if not isinstance(u, dict):
|
|
43
|
+
return None
|
|
44
|
+
tu = TokenUsage(
|
|
45
|
+
uncached_input_tokens=_coerce_int(u.get("input_tokens")),
|
|
46
|
+
output_tokens=_coerce_int(u.get("output_tokens")),
|
|
47
|
+
cache_creation_input_tokens=_coerce_int(u.get("cache_creation_input_tokens")),
|
|
48
|
+
cache_read_input_tokens=_coerce_int(u.get("cache_read_input_tokens")),
|
|
49
|
+
)
|
|
50
|
+
return None if tu.is_empty() else tu
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Isolated sandbox-copy + Claude Code SDK subprocess lifecycle for judge-style criteria.
|
|
2
|
+
|
|
3
|
+
SECURITY: owns the hardening knobs in one place — symlink-stripping copy,
|
|
4
|
+
``setting_sources=[]`` enforcement, ignore patterns for ``.claude``/``.mcp.json``.
|
|
5
|
+
Any future sub-agent criterion inherits the same posture by construction.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import contextlib
|
|
12
|
+
import logging
|
|
13
|
+
import shutil
|
|
14
|
+
import tempfile
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import TYPE_CHECKING, Any
|
|
17
|
+
|
|
18
|
+
from coder_eval.agents.claude_code_agent import ClaudeCodeAgent
|
|
19
|
+
from coder_eval.evaluation.verdict_tool import VerdictCapture
|
|
20
|
+
from coder_eval.models import ClaudeCodeAgentConfig
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from coder_eval.models.results import TurnRecord
|
|
25
|
+
from coder_eval.models.routing import ApiRoute
|
|
26
|
+
from coder_eval.sandbox import Sandbox
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _ignore_patterns_and_symlinks(patterns: list[str]):
|
|
33
|
+
"""``copytree`` ``ignore`` callable that drops pattern matches AND every symlink.
|
|
34
|
+
|
|
35
|
+
Symlinks in the sandbox — whether malicious or accidental — are rejected
|
|
36
|
+
rather than dereferenced into the judge workspace, which would leak host
|
|
37
|
+
files (e.g. a ``creds -> /root/.aws/credentials`` plant) to a Bash-enabled
|
|
38
|
+
judge.
|
|
39
|
+
"""
|
|
40
|
+
pattern_ignore = shutil.ignore_patterns(*patterns)
|
|
41
|
+
|
|
42
|
+
def _ignore(src: str, names: list[str]) -> set[str]:
|
|
43
|
+
ignored = set(pattern_ignore(src, names))
|
|
44
|
+
src_path = Path(src)
|
|
45
|
+
for name in names:
|
|
46
|
+
if name in ignored:
|
|
47
|
+
continue
|
|
48
|
+
if (src_path / name).is_symlink():
|
|
49
|
+
ignored.add(name)
|
|
50
|
+
return ignored
|
|
51
|
+
|
|
52
|
+
return _ignore
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class SubAgentRunner:
|
|
56
|
+
"""Spawn a Claude Code SDK agent in an isolated sandbox copy and return its turn.
|
|
57
|
+
|
|
58
|
+
Owns:
|
|
59
|
+
- ``mkdtemp`` + ``copytree`` with symlink filtering + pattern ignores.
|
|
60
|
+
- ``ClaudeCodeAgent`` construction, ``start``/``communicate``/``stop``/``kill`` lifecycle.
|
|
61
|
+
- Temp-dir cleanup on every exit path.
|
|
62
|
+
|
|
63
|
+
Does NOT own:
|
|
64
|
+
- Verdict parsing — caller does that on the returned ``TurnRecord``.
|
|
65
|
+
- Error-to-CriterionResult mapping — caller maps exceptions to their own result type.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
*,
|
|
71
|
+
sandbox: Sandbox,
|
|
72
|
+
agent_config: ClaudeCodeAgentConfig,
|
|
73
|
+
ignore_patterns: list[str],
|
|
74
|
+
route: ApiRoute,
|
|
75
|
+
reference_dir: Path | None = None,
|
|
76
|
+
reference_ignore_patterns: list[str] | None = None,
|
|
77
|
+
extra_mcp_servers: dict[str, Any] | None = None,
|
|
78
|
+
capture: VerdictCapture | None = None,
|
|
79
|
+
) -> None:
|
|
80
|
+
# SECURITY: setting_sources=[] is enforced by the caller — it's the caller's
|
|
81
|
+
# responsibility to build the AgentConfig correctly because the field is part of
|
|
82
|
+
# the type contract. We raise rather than mutate so a misconfigured caller fails
|
|
83
|
+
# loudly instead of silently having its config changed. Not `assert` because the
|
|
84
|
+
# check must survive `python -O`.
|
|
85
|
+
if agent_config.setting_sources != []:
|
|
86
|
+
raise ValueError(
|
|
87
|
+
"SubAgentRunner requires agent_config.setting_sources=[] so the SDK does not "
|
|
88
|
+
+ "load .claude/settings.json or .mcp.json from the sub-agent's working directory."
|
|
89
|
+
)
|
|
90
|
+
assert sandbox.sandbox_dir is not None, "sandbox not initialized"
|
|
91
|
+
self._sandbox = sandbox
|
|
92
|
+
self._agent_config = agent_config
|
|
93
|
+
self._ignore_patterns = ignore_patterns
|
|
94
|
+
self._route = route
|
|
95
|
+
# When set, copied into ``judge_dir / "_reference"`` after the main sandbox
|
|
96
|
+
# copy. The judge can browse it via Read/Glob. ``None`` skips the copy —
|
|
97
|
+
# callers MUST set this to None when the criterion has ``include_reference=False``
|
|
98
|
+
# so the judge can't see grading material it was opted out of.
|
|
99
|
+
self._reference_dir = reference_dir
|
|
100
|
+
# Separate ignore set for the reference-side copytree. Defaults to ``[]``
|
|
101
|
+
# so we DON'T silently strip a nested ``_reference/`` inside the user's
|
|
102
|
+
# reference dir — the sandbox-side ignore set legitimately contains
|
|
103
|
+
# ``_reference`` (defense-in-depth against agent-planted collisions at
|
|
104
|
+
# the mount point), but reusing it here would silently drop a customer
|
|
105
|
+
# subdir of the same name. Symlinks are stripped unconditionally by
|
|
106
|
+
# ``_ignore_patterns_and_symlinks([])``.
|
|
107
|
+
self._reference_ignore_patterns = reference_ignore_patterns or []
|
|
108
|
+
# Runtime-only in-process MCP server injection (e.g. the judge
|
|
109
|
+
# submit_verdict tool). NOT routed through ``sdk_options`` —
|
|
110
|
+
# ``mcp_servers`` is in ``_FRAMEWORK_OWNED_SDK_FIELDS``.
|
|
111
|
+
self._extra_mcp_servers = extra_mcp_servers or {}
|
|
112
|
+
# Public attribute so the criterion can read it after ``run()`` returns.
|
|
113
|
+
# When the caller passes ``capture=None`` the runner doesn't expose one,
|
|
114
|
+
# matching the opt-in-per-construction contract for the verdict channel.
|
|
115
|
+
self.capture = capture
|
|
116
|
+
|
|
117
|
+
def run(self, user_msg: str, *, max_turns: int | None, turn_timeout: float) -> TurnRecord:
|
|
118
|
+
"""Copy sandbox → start agent → communicate → stop. Kill on any exception.
|
|
119
|
+
|
|
120
|
+
Raises ``TurnTimeoutError`` when the agent exceeds ``turn_timeout``.
|
|
121
|
+
"""
|
|
122
|
+
# Narrow via local var — checked in __init__ but pyright doesn't track that.
|
|
123
|
+
src_dir = self._sandbox.sandbox_dir
|
|
124
|
+
assert src_dir is not None, "sandbox not initialized"
|
|
125
|
+
|
|
126
|
+
judge_dir = Path(tempfile.mkdtemp(prefix="sub_agent_"))
|
|
127
|
+
try:
|
|
128
|
+
# Copy the sandbox into an isolated temp dir. The sub-agent never touches
|
|
129
|
+
# the original sandbox, so later criteria are unaffected by whatever it
|
|
130
|
+
# does. Symlinks are skipped (vs preserved) so a malicious
|
|
131
|
+
# `creds -> /root/.aws/credentials` plant can't leak host files to a
|
|
132
|
+
# Bash-enabled sub-agent.
|
|
133
|
+
shutil.copytree(
|
|
134
|
+
src_dir,
|
|
135
|
+
judge_dir,
|
|
136
|
+
symlinks=True,
|
|
137
|
+
ignore=_ignore_patterns_and_symlinks(self._ignore_patterns),
|
|
138
|
+
dirs_exist_ok=True, # mkdtemp already created the target; allow merging in
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# Mount the reference solution at ``_reference/`` for the judge to browse.
|
|
142
|
+
# Symlinks are stripped unconditionally by the helper. Pattern-based
|
|
143
|
+
# ignores use a SEPARATE list (``_reference_ignore_patterns``, default
|
|
144
|
+
# ``[]``) so we don't reuse the sandbox-side ``ignore_patterns`` which
|
|
145
|
+
# includes ``_reference`` as defense-in-depth — applying that here would
|
|
146
|
+
# silently strip a nested ``_reference/`` subdir inside the user's
|
|
147
|
+
# reference, dropping grading material the user explicitly chose to
|
|
148
|
+
# include.
|
|
149
|
+
#
|
|
150
|
+
# Defense-in-depth on the sandbox side: ``AgentJudgeCriterion.agent.ignore_patterns``
|
|
151
|
+
# includes ``_reference`` so the first copytree above strips any
|
|
152
|
+
# sandbox-side ``_reference/`` (agent-planted or template-staged). If a
|
|
153
|
+
# caller overrides ignore_patterns and removes ``_reference``, the second
|
|
154
|
+
# copytree would FileExistsError; rmtree is the safety net AND ensures the
|
|
155
|
+
# judge sees grading material exclusively from ``task.reference``.
|
|
156
|
+
if self._reference_dir is not None:
|
|
157
|
+
ref_dest = judge_dir / "_reference"
|
|
158
|
+
if ref_dest.exists():
|
|
159
|
+
shutil.rmtree(ref_dest, ignore_errors=True)
|
|
160
|
+
# Deliberately NO ``dirs_exist_ok=True`` here. The rmtree above
|
|
161
|
+
# is the canonical clear; if any file survives (read-only flag,
|
|
162
|
+
# ENOTEMPTY race, hostile permission bits), we want copytree to
|
|
163
|
+
# FileExistsError loudly rather than silently merge the reference
|
|
164
|
+
# into agent-planted content under the same path. Loud failure on
|
|
165
|
+
# a partial-rmtree edge case is preferred over silently grading
|
|
166
|
+
# against a tampered ``_reference/``.
|
|
167
|
+
shutil.copytree(
|
|
168
|
+
self._reference_dir,
|
|
169
|
+
ref_dest,
|
|
170
|
+
symlinks=True,
|
|
171
|
+
ignore=_ignore_patterns_and_symlinks(self._reference_ignore_patterns),
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
agent = ClaudeCodeAgent(
|
|
175
|
+
self._agent_config,
|
|
176
|
+
route=self._route,
|
|
177
|
+
extra_mcp_servers=self._extra_mcp_servers,
|
|
178
|
+
)
|
|
179
|
+
logger.info(
|
|
180
|
+
"sub_agent: starting (model=%s, max_turns=%s, allowed_tools=%s)",
|
|
181
|
+
self._agent_config.model,
|
|
182
|
+
max_turns,
|
|
183
|
+
self._agent_config.allowed_tools,
|
|
184
|
+
)
|
|
185
|
+
# Safe because callers run check_all via asyncio.to_thread, so this
|
|
186
|
+
# invocation is on a worker thread with no active event loop. A direct
|
|
187
|
+
# async caller would get RuntimeError — acceptable for the architecture.
|
|
188
|
+
turn = asyncio.run(
|
|
189
|
+
self._run_agent(
|
|
190
|
+
agent,
|
|
191
|
+
judge_dir,
|
|
192
|
+
user_msg,
|
|
193
|
+
max_turns,
|
|
194
|
+
turn_timeout,
|
|
195
|
+
plugin_tools_dir=self._sandbox.plugin_tools_dir,
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
logger.info(
|
|
199
|
+
"sub_agent: finished (duration=%.1fs, tokens=%s)",
|
|
200
|
+
turn.duration_seconds,
|
|
201
|
+
turn.token_usage,
|
|
202
|
+
)
|
|
203
|
+
return turn
|
|
204
|
+
finally:
|
|
205
|
+
shutil.rmtree(judge_dir, ignore_errors=True)
|
|
206
|
+
|
|
207
|
+
@staticmethod
|
|
208
|
+
async def _run_agent(
|
|
209
|
+
agent: ClaudeCodeAgent,
|
|
210
|
+
judge_dir: Path,
|
|
211
|
+
user_msg: str,
|
|
212
|
+
max_turns: int | None,
|
|
213
|
+
turn_timeout: float,
|
|
214
|
+
*,
|
|
215
|
+
plugin_tools_dir: str | None = None,
|
|
216
|
+
) -> TurnRecord:
|
|
217
|
+
"""Run the sub-agent. Hard-kill on any exit path.
|
|
218
|
+
|
|
219
|
+
``stop()`` is cooperative; the SDK's anyio task groups can swallow
|
|
220
|
+
cancellation, so ``kill()`` is required to guarantee the subprocess dies.
|
|
221
|
+
"""
|
|
222
|
+
try:
|
|
223
|
+
await agent.start(str(judge_dir), plugin_tools_dir=plugin_tools_dir)
|
|
224
|
+
return await agent.communicate(user_msg, timeout=turn_timeout, max_turns=max_turns)
|
|
225
|
+
except BaseException:
|
|
226
|
+
with contextlib.suppress(Exception):
|
|
227
|
+
await agent.kill()
|
|
228
|
+
raise
|
|
229
|
+
finally:
|
|
230
|
+
with contextlib.suppress(Exception):
|
|
231
|
+
await agent.stop()
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Shared formatters for evaluation-related summaries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from coder_eval.models import CommandTelemetry
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def summarize_commands(commands: list[CommandTelemetry]) -> str | None:
|
|
13
|
+
"""Build a concise one-line-per-command summary of agent tool calls.
|
|
14
|
+
|
|
15
|
+
Used by the llm_judge criterion checker.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
commands: Ordered list of ``CommandTelemetry`` entries (typically
|
|
19
|
+
``turn_record.commands``).
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
Formatted multi-line string, or ``None`` when ``commands`` is empty.
|
|
23
|
+
"""
|
|
24
|
+
if not commands:
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
lines = []
|
|
28
|
+
for i, cmd in enumerate(commands, 1):
|
|
29
|
+
status = cmd.result_status or "unknown"
|
|
30
|
+
# Extract the most useful parameter for each tool type
|
|
31
|
+
detail = ""
|
|
32
|
+
params = cmd.parameters
|
|
33
|
+
if cmd.tool_name == "Bash" and "command" in params:
|
|
34
|
+
detail = f" `{params['command'][:120]}`"
|
|
35
|
+
elif cmd.tool_name in ("Read", "Write", "Edit", "Glob") and "file_path" in params:
|
|
36
|
+
detail = f" {params['file_path']}"
|
|
37
|
+
elif cmd.tool_name == "Grep" and "pattern" in params:
|
|
38
|
+
detail = f" pattern={params['pattern'][:60]}"
|
|
39
|
+
elif cmd.tool_name in ("Task", "Agent"):
|
|
40
|
+
detail = f" ({params.get('description', '')[:60]})"
|
|
41
|
+
|
|
42
|
+
result_preview = ""
|
|
43
|
+
if cmd.result_summary:
|
|
44
|
+
result_preview = f" → {cmd.result_summary[:80]}"
|
|
45
|
+
|
|
46
|
+
lines.append(f" {i}. [{status}] {cmd.tool_name}{detail}{result_preview}")
|
|
47
|
+
|
|
48
|
+
return "\n".join(lines)
|