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