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,454 @@
1
+ """Telemetry and command statistics models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from typing import Annotated, Any, Literal
7
+
8
+ from pydantic import BaseModel, Discriminator, Field, computed_field, model_validator
9
+
10
+
11
+ class TokenUsage(BaseModel):
12
+ """Token usage from a single agent turn or aggregated across turns.
13
+
14
+ The prompt is split into three mutually-exclusive buckets that SUM to the
15
+ full prompt: ``uncached_input_tokens`` (fresh, billed at the input rate),
16
+ ``cache_creation_input_tokens`` (written to cache), and
17
+ ``cache_read_input_tokens`` (served from cache). ``input_tokens`` is the
18
+ DERIVED total of the three (the whole prompt the model processed), exposed
19
+ as a computed field so it can never drift from its parts.
20
+
21
+ Provider mapping for ``uncached_input_tokens``:
22
+
23
+ * Anthropic / Claude: the API ``input_tokens`` (the uncached slice);
24
+ ``cache_creation``/``cache_read`` as reported.
25
+ * OpenAI / Codex: ``input - cached`` (the fresh slice). ``cache_creation`` is
26
+ 0 — OpenAI bills no separate cache-write fee — and ``cache_read`` is the
27
+ cached prefix.
28
+
29
+ COST bills ``uncached_input_tokens`` at the input rate, NOT ``input_tokens``
30
+ (which already contains the cache buckets and would double-count them).
31
+ """
32
+
33
+ @model_validator(mode="before")
34
+ @classmethod
35
+ def _adopt_legacy_input_tokens(cls, data: Any) -> Any:
36
+ """Read pre-split ``task.json`` files without silently under-counting.
37
+
38
+ Before the uncached/derived split, the stored field was ``input_tokens``
39
+ and it carried the FULL prompt. It is now a computed field, so a naive
40
+ reload drops it and recomputes from the cache buckets only — turning a
41
+ stored ``input_tokens=1000`` into ``350``. When the new stored field
42
+ ``uncached_input_tokens`` is absent but a legacy ``input_tokens`` is
43
+ present, adopt the legacy value as ``uncached_input_tokens`` so the Python
44
+ read path agrees with the evalboard (which already falls back the same
45
+ way). Current-format payloads carry ``uncached_input_tokens`` and are
46
+ untouched.
47
+ """
48
+ if isinstance(data, dict) and "uncached_input_tokens" not in data and "input_tokens" in data:
49
+ data = {**data, "uncached_input_tokens": data["input_tokens"]}
50
+ return data
51
+
52
+ uncached_input_tokens: int = Field(
53
+ default=0,
54
+ description=(
55
+ "Fresh (uncached) input prompt tokens, billed at the input rate. Maps to "
56
+ "Anthropic's API input_tokens; for Codex == (input - cached). This is the "
57
+ "field cost is computed from, NOT input_tokens (the derived total)."
58
+ ),
59
+ )
60
+ output_tokens: int = Field(default=0, description="Output completion tokens")
61
+ cache_creation_input_tokens: int = Field(
62
+ default=0,
63
+ description="Tokens written to the prompt cache this call (Anthropic only; 0 for Codex).",
64
+ )
65
+ cache_read_input_tokens: int = Field(default=0, description="Tokens read from prompt cache")
66
+ total_cost_usd: float | None = Field(
67
+ default=None,
68
+ description=(
69
+ "Total cost in USD. Normally the agent SDK's billed total; on a "
70
+ "timed-out/killed turn (no terminal result message) it is backfilled "
71
+ "from the rate card via pricing.calculate_cost, so a multi-turn "
72
+ "or run total may blend SDK-billed and rate-card-estimated figures."
73
+ ),
74
+ )
75
+
76
+ @computed_field # type: ignore[prop-decorator]
77
+ @property
78
+ def input_tokens(self) -> int:
79
+ """Total prompt tokens = uncached + cache_creation + cache_read.
80
+
81
+ Derived (read-only) so it always equals the sum of its parts. This is the
82
+ whole prompt the model processed, NOT the billable-at-input-rate slice —
83
+ for cost use ``uncached_input_tokens``.
84
+ """
85
+ return self.uncached_input_tokens + self.cache_creation_input_tokens + self.cache_read_input_tokens
86
+
87
+ @property
88
+ def total_tokens(self) -> int:
89
+ """All tokens the turn processed: full prompt (input_tokens) + output."""
90
+ return self.input_tokens + self.output_tokens
91
+
92
+ def is_empty(self) -> bool:
93
+ """True when every token counter is zero (cost is ignored).
94
+
95
+ Used to decide whether SDK-parsed usage carries any real traffic
96
+ before attributing it to a turn/judge.
97
+ """
98
+ return (
99
+ self.uncached_input_tokens == 0
100
+ and self.output_tokens == 0
101
+ and self.cache_creation_input_tokens == 0
102
+ and self.cache_read_input_tokens == 0
103
+ )
104
+
105
+ def __add__(self, other: TokenUsage) -> TokenUsage:
106
+ """Field-wise sum. Cost is summed only over the non-None operands
107
+ (stays None when neither carries a cost). ``input_tokens`` is derived, so
108
+ summing the parts keeps the total consistent automatically."""
109
+ costs = [c for c in (self.total_cost_usd, other.total_cost_usd) if c is not None]
110
+ return TokenUsage(
111
+ uncached_input_tokens=self.uncached_input_tokens + other.uncached_input_tokens,
112
+ output_tokens=self.output_tokens + other.output_tokens,
113
+ cache_creation_input_tokens=self.cache_creation_input_tokens + other.cache_creation_input_tokens,
114
+ cache_read_input_tokens=self.cache_read_input_tokens + other.cache_read_input_tokens,
115
+ total_cost_usd=sum(costs) if costs else None,
116
+ )
117
+
118
+
119
+ class ContentBlock(BaseModel):
120
+ """One content block within a message, in emission order.
121
+
122
+ Can be a text block (user/assistant), thinking block (assistant),
123
+ tool_use block (assistant), or tool_result block (user).
124
+ """
125
+
126
+ block_type: Literal["text", "thinking", "tool_use", "tool_result"] = Field(
127
+ description="Block kind: text, thinking, tool_use, or tool_result."
128
+ )
129
+ sequence: int = Field(description="0-indexed position within the parent message.content_blocks list.")
130
+
131
+ text: str | None = Field(default=None, description="Text content. Set when block_type='text' or 'tool_result'.")
132
+
133
+ thinking: str | None = Field(
134
+ default=None,
135
+ description="Extended-thinking reasoning content. Set when block_type='thinking'.",
136
+ )
137
+ signature: str | None = Field(default=None, description="Anthropic-signed signature for the thinking block.")
138
+
139
+ tool_use_id: str | None = Field(
140
+ default=None,
141
+ description="Tool invocation ID; joins to CommandTelemetry. Set for tool_use or tool_result blocks.",
142
+ )
143
+ is_error: bool = Field(
144
+ default=False,
145
+ description="True if tool_result block represents an error.",
146
+ )
147
+
148
+
149
+ class UserMessage(BaseModel):
150
+ """One user-side message (text or tool result) within a TurnRecord.
151
+
152
+ For simulator-driven dialog mode: captures user utterances from the
153
+ simulator plus orchestrator-side wall-clock timing. Populated in
154
+ simulation runs only.
155
+
156
+ For tool results: captured when a tool_result UserMessage arrives
157
+ from the SDK, containing tool result blocks and error status.
158
+ """
159
+
160
+ role: Literal["user"] = "user"
161
+
162
+ text: str = Field(description="Utterance sent to the agent (stop token stripped).")
163
+ raw_text: str | None = Field(
164
+ default=None,
165
+ description="Simulator's raw output before stop-token stripping. None for pinned initial_prompt openers.",
166
+ )
167
+ stop_requested: bool | None = Field(
168
+ default=None, description="Whether the simulator emitted its stop token. None for pinned openers."
169
+ )
170
+
171
+ started_at: datetime | None = Field(
172
+ default=None, description="Wall-clock start of the simulator LLM call. None for pinned openers."
173
+ )
174
+ completed_at: datetime | None = Field(
175
+ default=None, description="Wall-clock end of the simulator LLM call. None for pinned openers."
176
+ )
177
+ generation_duration_ms: float | None = Field(
178
+ default=None, description="completed_at - started_at in milliseconds. None for pinned openers."
179
+ )
180
+
181
+ input_tokens: int = Field(default=0, description="Simulator prompt tokens (0 if SDK did not surface them).")
182
+ output_tokens: int = Field(default=0, description="Simulator completion tokens (0 if SDK did not surface them).")
183
+ model: str | None = Field(default=None, description="Simulator model identifier.")
184
+ simulator_failed: bool = Field(
185
+ default=False, description="True if the simulator call raised; text/raw_text reflect the fallback or are empty."
186
+ )
187
+
188
+
189
+ class AssistantMessage(BaseModel):
190
+ """One LLM response message (from SDK AssistantMessage) within a TurnRecord.
191
+
192
+ Captures per-message generation timing, content blocks, and token usage so
193
+ LLM time can be measured directly rather than inferred from gaps between
194
+ command timestamps. Per-message token usage is per-generation. The run's
195
+ authoritative cumulative billing comes from the SDK ResultMessage
196
+ ``model_usage`` (cumulative per-model, reconciles to ``total_cost_usd``);
197
+ summing this stream is only a fallback when ``model_usage`` is absent (e.g.
198
+ a mock or non-conformant SDK). The stream under-reports sub-agent cache-creation/input — those
199
+ emissions are only partially bubbled up — so do NOT treat it as the source of
200
+ truth for run-level cost.
201
+ """
202
+
203
+ role: Literal["assistant"] = "assistant"
204
+
205
+ started_at: datetime = Field(description="Wall-clock start of generation (end of the previous SDK event).")
206
+ completed_at: datetime = Field(description="Wall-clock arrival of the AssistantMessage from the SDK.")
207
+ generation_duration_ms: float = Field(
208
+ description="completed_at - started_at in milliseconds (wall-clock between SDK events).",
209
+ )
210
+
211
+ content_blocks: list[ContentBlock] = Field(
212
+ default_factory=list,
213
+ description="Content blocks Claude emitted in this turn, in emission order.",
214
+ )
215
+ tool_use_ids: list[str] = Field(
216
+ default_factory=list,
217
+ description="tool_use_id values from content_blocks, for joining with CommandTelemetry and tool_result blocks.",
218
+ )
219
+
220
+ input_tokens: int = Field(
221
+ default=0,
222
+ description=(
223
+ "Input prompt tokens for this LLM call. Zero on follow-up emissions that "
224
+ "share a message_id with an earlier AssistantMessage (the CLI splits one "
225
+ "API call into multiple emissions); billing is recorded on the first only. "
226
+ "Per-call only; the run's cumulative input is taken from ResultMessage "
227
+ "model_usage (summing this stream is a fallback — it misses sub-agent input)."
228
+ ),
229
+ )
230
+ output_tokens: int = Field(
231
+ default=0,
232
+ description=(
233
+ "Output tokens generated in this LLM call. Recovered from the "
234
+ "message_delta stream event when include_partial_messages is on; falls "
235
+ "back to the (often partial) SDK assistant-event value otherwise. Per-call "
236
+ "only; the run's cumulative output is taken from ResultMessage model_usage "
237
+ "(summing this stream is a fallback)."
238
+ ),
239
+ )
240
+ cache_creation_tokens: int = Field(default=0, description="Tokens used to create prompt cache for this call.")
241
+ cache_read_tokens: int = Field(default=0, description="Tokens read from prompt cache for this call.")
242
+ reasoning_tokens: int = Field(default=0, description="Extended-thinking tokens; subset of output_tokens.")
243
+
244
+ stop_reason: str | None = Field(default=None, description="SDK stop reason: 'tool_use', 'end_turn', etc.")
245
+ model: str | None = Field(default=None, description="Model identifier that generated this turn.")
246
+
247
+ message_id: str | None = Field(
248
+ default=None,
249
+ description=(
250
+ "Anthropic API message_id. Multiple AssistantMessage records can share this id "
251
+ "when the Claude Code CLI splits one API response into per-block-kind events. "
252
+ "Downstream tooling can group by this id to recover one logical generation."
253
+ ),
254
+ )
255
+
256
+ parent_tool_use_id: str | None = Field(
257
+ default=None,
258
+ description=(
259
+ "The Task tool_use_id that spawned this message's sub-agent, or null for the "
260
+ "main agent thread. Sub-agent (Task tool) calls bubble up into the same message "
261
+ "stream, but each sub-agent has its OWN context that is not re-read by the main "
262
+ "thread or sibling sub-agents — so the conversation is a tree, not a flat list. "
263
+ "Grouping by this id recovers the branch a call belongs to, which is required to "
264
+ "model the prompt-cache re-read cascade correctly (content cascades only within "
265
+ "its own branch). Sourced from the SDK AssistantMessage.parent_tool_use_id."
266
+ ),
267
+ )
268
+
269
+
270
+ class ReconciliationMessage(BaseModel):
271
+ """A synthetic transcript entry carrying tokens the agent billed but never
272
+ surfaced as a streamed generation.
273
+
274
+ The authoritative turn total (Claude's ``ResultMessage.model_usage``, Codex's
275
+ thread total + folded sub-agent tokens) is consistently LARGER than the sum
276
+ of the per-``AssistantMessage`` token buckets, for reasons that are
277
+ architectural, not bugs: a fixed prompt slice (~512 input tokens on Claude)
278
+ is billed but rides on no SDK-emitted message, and sub-agent input/cache is
279
+ only partially bubbled into the parent stream. Rather than fabricate
280
+ per-generation numbers (which would match no real call), the residual is
281
+ booked once, explicitly, as this entry — so that summing the transcript's
282
+ token buckets EXACTLY reproduces the authoritative ``token_usage`` for the
283
+ turn. Consumers that sum the stream (the evalboard) therefore reconcile to
284
+ the bill without a separate aggregate; this entry is the visible "missing
285
+ tokens" line. Carries no cost (cost stays on the authoritative aggregate) and
286
+ is never an LLM generation — it is excluded from generation/turn counts.
287
+ """
288
+
289
+ role: Literal["reconciliation"] = "reconciliation"
290
+
291
+ input_tokens: int = Field(
292
+ default=0,
293
+ description="Residual uncached input tokens (authoritative turn total minus the per-message sum).",
294
+ )
295
+ output_tokens: int = Field(default=0, description="Residual output tokens.")
296
+ cache_creation_tokens: int = Field(default=0, description="Residual cache-creation tokens.")
297
+ cache_read_tokens: int = Field(default=0, description="Residual cache-read tokens.")
298
+
299
+ note: str = Field(
300
+ default="",
301
+ description="Human-readable explanation of why these tokens are unattributed (shown in the UI).",
302
+ )
303
+
304
+
305
+ class CommandTelemetry(BaseModel):
306
+ """Telemetry for a single command execution.
307
+
308
+ Captures detailed information about each tool use by the agent,
309
+ enabling analysis, debugging, and optimization.
310
+
311
+ **Deprecation notice:** This model duplicates data already available in
312
+ TurnRecord.messages (as tool_use and tool_result blocks). In future versions,
313
+ command telemetry should be derived from the messages array rather than
314
+ maintained as a parallel structure. Kept for backward compatibility.
315
+ """
316
+
317
+ # Identity
318
+ tool_name: str = Field(description="Tool name (Read, Write, Bash, Edit, Glob, etc.)")
319
+ tool_id: str = Field(description="Unique ID from Claude SDK for this tool invocation")
320
+
321
+ assistant_turn_index: int | None = Field(
322
+ default=None,
323
+ description=(
324
+ "0-indexed position among AssistantMessage entries only. "
325
+ "Filter turn_record.messages by role='assistant' before indexing."
326
+ ),
327
+ )
328
+
329
+ # Timing: generation_completed_at is when Claude finished emitting the
330
+ # tool_use block; execution_* bracket the actual tool run. `timestamp`
331
+ # equals generation_completed_at and `duration_ms` equals
332
+ # execution_completed_at - execution_started_at; both are retained for
333
+ # downstream consumers that index on the original field names.
334
+ timestamp: datetime = Field(description="Equal to generation_completed_at; when the tool_use block arrived.")
335
+ duration_ms: float | None = Field(
336
+ default=None,
337
+ description="Command execution time in milliseconds (None = not complete, set in two-phase processing).",
338
+ )
339
+ generation_completed_at: datetime | None = Field(
340
+ default=None,
341
+ description="When Claude finished emitting the tool_use block.",
342
+ )
343
+ execution_started_at: datetime | None = Field(default=None, description="When tool execution began.")
344
+ execution_completed_at: datetime | None = Field(
345
+ default=None, description="When tool execution completed (success or error)."
346
+ )
347
+
348
+ # Parameters (structured)
349
+ parameters: dict[str, Any] = Field(
350
+ default_factory=dict, description="Structured command parameters (e.g., {'file_path': 'main.py'} for Read)"
351
+ )
352
+
353
+ # Results
354
+ result_status: Literal["success", "error", "unknown"] | None = Field(
355
+ default=None,
356
+ description="Whether the command succeeded or failed (None = pending result, set during two-phase processing)",
357
+ )
358
+ result_summary: str | None = Field(
359
+ default=None,
360
+ description=(
361
+ "Full tool-result content as text (untruncated). Despite the name, this is "
362
+ "the complete result body, not a preview — kept whole so sub-agent returns "
363
+ "and other large payloads are preserved."
364
+ ),
365
+ )
366
+ error_message: str | None = Field(default=None, description="Error message if command failed")
367
+ result_data: dict[str, Any] | list[Any] | None = Field(
368
+ default=None,
369
+ description=(
370
+ "First parsed JSON object or array found in the tool result content. "
371
+ "Prefix noise (e.g. CLI warning lines before the JSON body) and trailing "
372
+ "garbage are tolerated. None for content without any parseable JSON object "
373
+ "or array and for bare primitives (strings, numbers, booleans, null). "
374
+ "Complements result_summary (a short text preview) by preserving the full "
375
+ "structured payload so downstream consumers can extract specialised views "
376
+ "without re-parsing the log."
377
+ ),
378
+ )
379
+
380
+ # Metadata
381
+ sequence_number: int = Field(default=0, description="Order within the turn (0-indexed)")
382
+
383
+ @computed_field # type: ignore[prop-decorator]
384
+ @property
385
+ def result_tokens(self) -> int:
386
+ """Approximate token size of the tool result the model received.
387
+
388
+ Derived from the untruncated ``result_summary`` content (≈4 chars/token,
389
+ matching the evalboard heuristic), so it is a DIRECT, cache-independent
390
+ measure of "tool output size" — available identically whether prompt
391
+ caching was on or off. The cost simulator uses this instead of inferring
392
+ result size from prompt-cache growth (which is unavailable when caching is
393
+ disabled). Approximate, not the API's exact tokenizer count, but
394
+ deterministic and always present. 0 when the tool returned no content.
395
+ """
396
+ if not self.result_summary:
397
+ return 0
398
+ return -(-len(self.result_summary) // 4) # ceil(len / 4)
399
+
400
+
401
+ #: One entry in a turn's per-message transcript, discriminated on ``role``.
402
+ #: ``ReconciliationMessage`` is the synthetic "missing tokens" line that makes the
403
+ #: transcript's token buckets sum to the authoritative turn total. Defined once
404
+ #: here and reused by ``TurnRecord.messages`` and ``AgentEndEvent.messages`` so the
405
+ #: list element type is identical everywhere (avoids list-invariance friction).
406
+ TranscriptMessage = Annotated[UserMessage | AssistantMessage | ReconciliationMessage, Discriminator("role")]
407
+
408
+
409
+ class SlowestCommandInfo(BaseModel):
410
+ """Information about a slow command for performance analysis.
411
+
412
+ Type-safe model for reporting slowest commands in statistics.
413
+ """
414
+
415
+ tool: str = Field(description="Tool name (e.g., 'Bash', 'Read')")
416
+ duration_ms: float = Field(description="Execution duration in milliseconds")
417
+ parameters: dict[str, Any] = Field(description="Command parameters")
418
+ tool_id: str | None = Field(default=None, description="Optional: Unique tool invocation ID")
419
+
420
+
421
+ class CommandStatistics(BaseModel):
422
+ """Aggregated statistics for command usage in an evaluation.
423
+
424
+ Provides summary metrics for analysis and reporting.
425
+ """
426
+
427
+ total_commands: int = Field(description="Total number of commands executed")
428
+ commands_by_tool: dict[str, int] = Field(
429
+ default_factory=dict, description="Count of commands per tool (e.g., {'Bash': 45, 'Read': 12})"
430
+ )
431
+
432
+ # Timing
433
+ total_command_time_ms: float = Field(default=0.0, description="Total time spent executing commands (milliseconds)")
434
+ avg_command_time_ms: float | None = Field(default=None, description="Average command execution time")
435
+ slowest_commands: list[SlowestCommandInfo] = Field(
436
+ default_factory=list, description="Top 5 slowest commands with details (type-safe model)"
437
+ )
438
+
439
+ # Success/Failure
440
+ successful_commands: int = Field(default=0, description="Commands that succeeded")
441
+ failed_commands: int = Field(default=0, description="Commands that failed")
442
+ unknown_commands: int = Field(
443
+ default=0,
444
+ description="Commands with unknown status (missing ResultMessage, indicates agent/SDK interruption)",
445
+ )
446
+ success_rate: float = Field(
447
+ default=0.0,
448
+ description="Percentage of known commands that succeeded: success / (success + failed), excludes unknown",
449
+ )
450
+
451
+ # Patterns
452
+ most_common_sequence: str | None = Field(
453
+ default=None, description="Most common 3-command sequence (e.g., 'Read -> Edit -> Bash')"
454
+ )
@@ -0,0 +1,108 @@
1
+ """Template source models for sandbox initialization."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from abc import ABC
7
+ from typing import Annotated, Any, Literal
8
+
9
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
10
+
11
+
12
+ class StarterFile(BaseModel):
13
+ """A file to create in the sandbox before agent starts."""
14
+
15
+ model_config = ConfigDict(extra="forbid")
16
+
17
+ path: str = Field(description="Relative path in sandbox (e.g., 'src/main.py')")
18
+ content: str = Field(description="File content")
19
+
20
+
21
+ class BaseTemplateSource(BaseModel, ABC):
22
+ """Base class for template sources - defines the discriminated union.
23
+
24
+ Note: No type field here - each subclass defines its own Literal type.
25
+ """
26
+
27
+ model_config = ConfigDict(extra="forbid")
28
+
29
+ def model_post_init(self, context: Any, /) -> None:
30
+ # Pin the discriminator tag into model_fields_set so it survives
31
+ # model_dump(exclude_unset=True) → model_validate() round-trips (the
32
+ # sandbox layer merge dumps with exclude_unset) even for
33
+ # directly-constructed sources, where the tag comes from the Literal
34
+ # default rather than the caller.
35
+ self.__pydantic_fields_set__.add("type")
36
+
37
+
38
+ class TemplateDirSource(BaseTemplateSource):
39
+ """Copy files from a local directory into the sandbox."""
40
+
41
+ type: Literal["template_dir"] = "template_dir"
42
+ path: str = Field(description="Path to template directory (relative to task YAML or absolute)")
43
+ mount_point: str = Field(
44
+ default=".",
45
+ description=(
46
+ "Destination subdirectory inside the sandbox where the template contents are copied. "
47
+ "Defaults to '.' (sandbox root). Must be a relative path that stays within the sandbox."
48
+ ),
49
+ )
50
+ include_patterns: list[str] = Field(
51
+ default_factory=list,
52
+ description=(
53
+ "Template-relative glob patterns to copy even when the path matches a default sandbox "
54
+ "ignore pattern. Author-controlled — re-including sensitive directories such as `.git` "
55
+ "or `.env` is possible, so review patterns when writing tasks. `*` does not stop at `/`, "
56
+ "see `_matches_template_include_pattern`."
57
+ ),
58
+ )
59
+
60
+ @field_validator("mount_point")
61
+ @classmethod
62
+ def _validate_mount_point(cls, v: str) -> str:
63
+ if not v:
64
+ raise ValueError("mount_point must not be empty")
65
+ if os.path.isabs(v) or v.startswith(("/", "\\")):
66
+ raise ValueError(f"mount_point must be a relative path, got: {v!r}")
67
+ parts = v.replace("\\", "/").split("/")
68
+ if any(p == ".." for p in parts):
69
+ raise ValueError(f"mount_point must not contain '..' segments, got: {v!r}")
70
+ return v
71
+
72
+ @field_validator("include_patterns")
73
+ @classmethod
74
+ def _validate_include_patterns(cls, v: list[str]) -> list[str]:
75
+ for pattern in v:
76
+ if not pattern:
77
+ raise ValueError("include_patterns entries must not be empty")
78
+ if os.path.isabs(pattern) or pattern.startswith(("/", "\\")):
79
+ raise ValueError(f"include_patterns entries must be relative, got: {pattern!r}")
80
+ parts = pattern.replace("\\", "/").split("/")
81
+ if any(p == ".." for p in parts):
82
+ raise ValueError(f"include_patterns entries must not contain '..' segments, got: {pattern!r}")
83
+ return v
84
+
85
+
86
+ class StarterFilesSource(BaseTemplateSource):
87
+ """Create inline files from YAML definitions."""
88
+
89
+ type: Literal["starter_files"] = "starter_files"
90
+ files: list[StarterFile] = Field(description="List of files to create")
91
+
92
+
93
+ class RepoSource(BaseTemplateSource):
94
+ """Clone files from a git repository."""
95
+
96
+ type: Literal["repo"] = "repo"
97
+ url: str = Field(description="Git repository URL")
98
+ commit: str | None = Field(default=None, description="Specific commit SHA to checkout")
99
+
100
+
101
+ # Discriminated union of template sources. The `type` tag is REQUIRED in
102
+ # dict/YAML input — a missing or unknown tag raises one crisp discriminator
103
+ # error instead of smart-union coercion. Per-variant Literal defaults remain
104
+ # for direct construction and serialization.
105
+ TemplateSource = Annotated[
106
+ TemplateDirSource | StarterFilesSource | RepoSource,
107
+ Field(discriminator="type"),
108
+ ]
@@ -0,0 +1,13 @@
1
+ """Orchestration components for task evaluation lifecycle.
2
+
3
+ This package coordinates the complete evaluation flow from task loading
4
+ through sandbox setup, agent interaction, success checking, and cleanup.
5
+
6
+ Main components:
7
+ - config: Configuration models for batch execution
8
+ - task_loader: YAML task definition loading and validation
9
+
10
+ NO re-exports - use explicit imports:
11
+ from coder_eval.orchestration.config import BatchRunConfig
12
+ from coder_eval.orchestration.task_loader import load_task, resolve_template_paths
13
+ """