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,198 @@
1
+ """Standardized streaming events for agent execution.
2
+
3
+ Every event originates inside ``Agent.communicate()`` (the agent is the *single*
4
+ emitter — the orchestrator is a pure consumer). Events form a tree mirroring
5
+ execution and close every Start with a matching End on every exit path:
6
+
7
+ AgentStartEvent(thread_id=A)
8
+ TurnStartEvent(thread_id=A, turn_id=t1)
9
+ TextChunkEvent(turn_id=t1)
10
+ ToolStartEvent(turn_id=t1, tool)
11
+ ToolEndEvent(turn_id=t1, tool, status)
12
+ TurnEndEvent(thread_id=A, turn_id=t1, status, tokens)
13
+ AgentEndEvent(thread_id=A, status, usage)
14
+
15
+ Self-describing via ``thread_id`` / ``parent_thread_id`` / ``turn_id`` so a
16
+ consumer can place each event in the tree without keeping its own state. Sub-agents
17
+ are *not* a separate event type — a sub-agent is a nested ``AgentStart``/``AgentEnd``
18
+ pair linked by ``parent_thread_id``.
19
+
20
+ Events are Pydantic ``BaseModel``s (keyword-only construction) and reuse the leaf
21
+ telemetry models (``TokenUsage``, ``CommandTelemetry``) rather than re-declaring
22
+ fields.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from datetime import datetime
28
+ from enum import StrEnum
29
+ from typing import Literal
30
+
31
+ from pydantic import BaseModel, ConfigDict, Field
32
+
33
+ from coder_eval.models import (
34
+ CommandTelemetry,
35
+ ResultSummary,
36
+ TokenUsage,
37
+ TranscriptMessage,
38
+ )
39
+
40
+
41
+ # --- Status codes -----------------------------------------------------------
42
+ #
43
+ # One enum per End-event level. Every End event carries the appropriate one so
44
+ # success, tool error, permission denial, crash, timeout and orphaned tool calls
45
+ # are a single mechanism (no ToolErrorEvent subclass + string scans).
46
+
47
+
48
+ class ToolEndStatus(StrEnum):
49
+ """Terminal status of a single tool call."""
50
+
51
+ OK = "ok" # tool ran, no error
52
+ ERROR = "error" # tool ran and returned an error
53
+ PERMISSION_DENIED = "permission_denied" # blocked by permission/sandbox
54
+ UNRESOLVED = "unresolved" # ToolStart emitted, no result observed (crash/timeout/orphan)
55
+
56
+
57
+ class TurnEndStatus(StrEnum):
58
+ """Terminal status of one inner turn (one API response / one Codex turn)."""
59
+
60
+ COMPLETED = "completed"
61
+ CRASHED = "crashed"
62
+ TIMEOUT = "timeout"
63
+ MAX_TURNS_EXHAUSTED = "max_turns_exhausted"
64
+
65
+
66
+ class AgentEndStatus(StrEnum):
67
+ """Terminal status of one agent invocation (a whole ``communicate()`` call)."""
68
+
69
+ COMPLETED = "completed"
70
+ CRASHED = "crashed"
71
+ TIMEOUT = "timeout"
72
+ MAX_TURNS_EXHAUSTED = "max_turns_exhausted"
73
+
74
+
75
+ # Reuse the canonical TranscriptMessage union (defined once in telemetry.py) so
76
+ # AgentEndEvent carries per-message telemetry losslessly and stays in lock-step
77
+ # with TurnRecord.messages (the deferred token path rides here, not on granular events).
78
+ _MessageList = list[TranscriptMessage]
79
+
80
+
81
+ class StreamEvent(BaseModel):
82
+ """Base class for all streaming events."""
83
+
84
+ model_config = ConfigDict(arbitrary_types_allowed=True)
85
+
86
+ task_id: str
87
+ timestamp: datetime = Field(default_factory=datetime.now)
88
+ thread_id: str | None = Field(
89
+ default=None,
90
+ description="Agent-context id. Main agent: SDK session id (None until assigned). Sub-agent: spawning tool id.",
91
+ )
92
+ parent_thread_id: str | None = Field(
93
+ default=None,
94
+ description="None for the main agent; the parent's thread_id for a sub-agent.",
95
+ )
96
+
97
+
98
+ class AgentStartEvent(StreamEvent):
99
+ """Emitted on the first line of ``communicate()`` (replaces the orchestrator's TurnStartEvent)."""
100
+
101
+ kind: Literal["agent_start"] = "agent_start"
102
+ prompt: str = ""
103
+ iteration: int = 0
104
+ model: str | None = None
105
+
106
+
107
+ class TurnStartEvent(StreamEvent):
108
+ """Emitted when one inner turn begins (Claude: per API call; Codex: once per communicate())."""
109
+
110
+ kind: Literal["turn_start"] = "turn_start"
111
+ turn_id: str = ""
112
+ model: str | None = None
113
+
114
+
115
+ class TextChunkEvent(StreamEvent):
116
+ """Emitted when the agent produces visible text output within a turn."""
117
+
118
+ kind: Literal["text_chunk"] = "text_chunk"
119
+ turn_id: str = ""
120
+ text: str = ""
121
+
122
+
123
+ class ToolStartEvent(StreamEvent):
124
+ """Emitted when the agent invokes a tool (the tool's result fields are pending)."""
125
+
126
+ kind: Literal["tool_start"] = "tool_start"
127
+ turn_id: str = ""
128
+ tool: CommandTelemetry
129
+
130
+
131
+ class ToolEndEvent(StreamEvent):
132
+ """Emitted when a tool returns its result (or is force-closed as unresolved on crash)."""
133
+
134
+ kind: Literal["tool_end"] = "tool_end"
135
+ turn_id: str = ""
136
+ tool: CommandTelemetry
137
+ status: ToolEndStatus = ToolEndStatus.OK
138
+
139
+
140
+ class TurnEndEvent(StreamEvent):
141
+ """Emitted at each inner-turn boundary; carries best-effort per-turn tokens."""
142
+
143
+ kind: Literal["turn_end"] = "turn_end"
144
+ turn_id: str = ""
145
+ status: TurnEndStatus = TurnEndStatus.COMPLETED
146
+ tokens: TokenUsage | None = None
147
+
148
+
149
+ class AgentEndEvent(StreamEvent):
150
+ """Emitted from ``finally`` at the end of ``communicate()`` — the turn finalization payload.
151
+
152
+ Carries the cumulative, authoritative ``usage`` plus the fields the
153
+ ``EventCollector`` cannot losslessly derive from granular events (the per-message
154
+ telemetry / token path the plan defers). ``EventCollector`` reduces commands from
155
+ the ``ToolEnd`` stream and reads everything else here.
156
+ """
157
+
158
+ kind: Literal["agent_end"] = "agent_end"
159
+ status: AgentEndStatus = AgentEndStatus.COMPLETED
160
+ usage: TokenUsage = Field(default_factory=TokenUsage)
161
+
162
+ # Finalization payload (the deferred token/messages path rides here).
163
+ iteration: int = 0
164
+ user_input: str = ""
165
+ agent_output: str = ""
166
+ model_used: str | None = None
167
+ assistant_turn_count: int = 0
168
+ messages: _MessageList = Field(default_factory=list)
169
+ num_turns: int | None = None
170
+ max_turns_exhausted: bool = False
171
+ result_summary: ResultSummary | None = None
172
+ crashed: bool = False
173
+ crash_reason: str | None = None
174
+ duration_seconds: float = 0.0
175
+
176
+
177
+ # --- Post-evaluation events (orchestrator-owned, not part of the agent lifecycle) ---
178
+
179
+
180
+ class CriterionSummary(BaseModel):
181
+ """Summary of a single criterion check for display."""
182
+
183
+ criterion_type: str = ""
184
+ description: str = ""
185
+ score: float = 0.0
186
+ passed: bool = False
187
+ failure_reason: str | None = None
188
+
189
+
190
+ class CriteriaCheckEvent(StreamEvent):
191
+ """Emitted after success criteria are evaluated."""
192
+
193
+ kind: Literal["criteria_check"] = "criteria_check"
194
+ passed: int = 0
195
+ total: int = 0
196
+ weighted_score: float = 0.0
197
+ details: list[str] = Field(default_factory=list)
198
+ criteria: list[CriterionSummary] = Field(default_factory=list)
@@ -0,0 +1,248 @@
1
+ """Rich terminal renderer for streaming events."""
2
+
3
+ import logging
4
+ import threading
5
+
6
+ from rich.console import Console
7
+ from rich.markup import escape
8
+
9
+ from coder_eval.formatting import format_payload, format_token_usage
10
+ from coder_eval.models import ResultSummary
11
+ from coder_eval.streaming.events import (
12
+ AgentEndEvent,
13
+ AgentStartEvent,
14
+ CriteriaCheckEvent,
15
+ CriterionSummary,
16
+ StreamEvent,
17
+ TextChunkEvent,
18
+ ToolEndEvent,
19
+ ToolEndStatus,
20
+ ToolStartEvent,
21
+ TurnEndEvent,
22
+ TurnStartEvent,
23
+ )
24
+
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ # Budgets tuned so that a typical JSON payload (agent params + one tool
30
+ # result) fits without truncation but runaway stdout still stays bounded.
31
+ _MAX_PARAMS_LEN = 800
32
+ _MAX_RESULT_LEN = 800
33
+
34
+
35
+ def _truncate(text: str, max_len: int) -> str:
36
+ """Truncate text with ellipsis if it exceeds max_len."""
37
+ if len(text) <= max_len:
38
+ return text
39
+ return text[: max_len - 3] + "..."
40
+
41
+
42
+ def _format_result_error(summary: ResultSummary | None) -> str | None:
43
+ """Compact SDK error detail from a ResultSummary, or None when not an error.
44
+
45
+ Surfaces the diagnostic detail of an ``is_error`` ResultMessage even on
46
+ exits the agent treats as clean (a benign error-subtype result, or a
47
+ ``max_turns`` short-circuit) — paths where ``crash_reason`` is unset
48
+ because the turn never raised. Both renderers share this so the detail is
49
+ rendered identically on the console and in task.log.
50
+ """
51
+ if summary is None or not summary.is_error:
52
+ return None
53
+ parts: list[str] = []
54
+ if summary.subtype:
55
+ parts.append(f"subtype={summary.subtype}")
56
+ if summary.stop_reason:
57
+ parts.append(f"stop_reason={summary.stop_reason}")
58
+ if summary.result:
59
+ parts.append(f"result={_truncate(summary.result, _MAX_RESULT_LEN)}")
60
+ return ", ".join(parts) if parts else "(no detail)"
61
+
62
+
63
+ class RichStreamRenderer:
64
+ """Renders streaming events to a Rich console."""
65
+
66
+ def __init__(
67
+ self,
68
+ console: Console | None = None,
69
+ verbosity: str = "full",
70
+ batch_mode: bool = False,
71
+ ) -> None:
72
+ self._console = console or Console(stderr=True)
73
+ self._verbosity = verbosity
74
+ self._batch_mode = batch_mode
75
+ self._lock = threading.Lock()
76
+
77
+ def on_event(self, event: StreamEvent) -> None:
78
+ """Render a streaming event to the console."""
79
+ if self._verbosity == "minimal" and isinstance(event, (ToolStartEvent, ToolEndEvent, TextChunkEvent)):
80
+ return
81
+
82
+ line = self._format_event(event)
83
+ if line is None:
84
+ return
85
+
86
+ if self._batch_mode:
87
+ line = f"[dim]\\[{escape(event.task_id)}][/dim] {line}"
88
+
89
+ with self._lock:
90
+ self._console.print(line, highlight=False)
91
+
92
+ def _format_event(self, event: StreamEvent) -> str | None:
93
+ """Format a single event into a Rich markup string."""
94
+ if isinstance(event, AgentStartEvent):
95
+ model = f" (model={escape(event.model)})" if event.model else ""
96
+ return f"[bold]--- Iteration {event.iteration}{model} ---[/bold]"
97
+
98
+ # TurnStartEvent is intentionally not rendered on the console: the Rich
99
+ # view is a terse live feed and Claude emits one per API call (noisy).
100
+ # The full turn tree lives in task.log via LoggingStreamRenderer.
101
+
102
+ if isinstance(event, ToolStartEvent):
103
+ params_str = escape(format_payload(event.tool.parameters, max_chars=_MAX_PARAMS_LEN))
104
+ return f"[cyan]>>> TOOL: {escape(event.tool.tool_name)}[/cyan] | {params_str}"
105
+
106
+ if isinstance(event, ToolEndEvent):
107
+ preview = escape(event.tool.result_summary or "")
108
+ if event.status == ToolEndStatus.OK:
109
+ tag = "[green]<<< OK[/green]"
110
+ elif event.status == ToolEndStatus.UNRESOLVED:
111
+ tag = "[yellow]<<< UNRESOLVED:[/yellow]"
112
+ else:
113
+ tag = f"[red]<<< {escape(event.status.value.upper())}:[/red]"
114
+ return f"{tag} ({len(preview)} chars) {preview}"
115
+
116
+ if isinstance(event, TextChunkEvent):
117
+ return f"[dim]{escape(event.text)}[/dim]"
118
+
119
+ if isinstance(event, AgentEndEvent):
120
+ usage_str = escape(format_token_usage(event.usage))
121
+ line = (
122
+ f"[bold]--- Turn complete: {len(event.messages)} msgs, "
123
+ f"{event.duration_seconds:.1f}s, {usage_str} ---[/bold]"
124
+ )
125
+ if event.max_turns_exhausted:
126
+ line += " [yellow](max_turns exhausted)[/yellow]"
127
+ if event.crashed and event.crash_reason:
128
+ line += f"\n[red] reason: {escape(event.crash_reason)}[/red]"
129
+ error_detail = _format_result_error(event.result_summary)
130
+ if error_detail is not None:
131
+ line += f"\n[red] detail: {escape(error_detail)}[/red]"
132
+ return line
133
+
134
+ if isinstance(event, CriteriaCheckEvent):
135
+ score_color = "green" if event.passed == event.total else "yellow"
136
+ header = (
137
+ f"[{score_color}]Criteria: {event.passed}/{event.total} passed"
138
+ + f" (score: {event.weighted_score:.3f})[/{score_color}]"
139
+ )
140
+ if event.criteria:
141
+ return self._format_criteria_details(header, event.criteria)
142
+ # Fallback to legacy flat details
143
+ if event.details:
144
+ header += f" \\[{escape(' | '.join(event.details))}]"
145
+ return header
146
+
147
+ return None
148
+
149
+ @staticmethod
150
+ def _format_criteria_details(header: str, criteria: list[CriterionSummary]) -> str:
151
+ """Format criteria with per-criterion lines and failure reasons.
152
+
153
+ For failed criteria, the first line of the failure reason is shown
154
+ at normal brightness; subsequent lines are dimmed.
155
+ """
156
+ lines = [header]
157
+ for c in criteria:
158
+ if c.passed:
159
+ lines.append(f" [green]PASS[/green] {escape(c.criterion_type)} {escape(c.description)}")
160
+ else:
161
+ lines.append(f" [red]FAIL[/red] {escape(c.criterion_type)} {escape(c.description)}")
162
+ if c.failure_reason:
163
+ reason_lines = c.failure_reason.splitlines()
164
+ lines.append(f" > {escape(reason_lines[0])}")
165
+ for extra in reason_lines[1:]:
166
+ lines.append(f" [dim]{escape(extra)}[/dim]")
167
+ return "\n".join(lines)
168
+
169
+
170
+ class LoggingStreamRenderer:
171
+ """Logs streaming events to the task logger (for task.log file capture).
172
+
173
+ Converts stream events into DEBUG log lines, making them available for
174
+ task.log persistence. This is the single, agent-agnostic place where the
175
+ event stream becomes task.log lines — agents emit events and get logging
176
+ for free, with no per-agent message-dumping.
177
+ """
178
+
179
+ def __init__(self) -> None:
180
+ self._lock = threading.Lock()
181
+
182
+ def on_event(self, event: StreamEvent) -> None:
183
+ """Log a streaming event to the task logger."""
184
+ line = self._format_event(event)
185
+ if line is None:
186
+ return
187
+
188
+ with self._lock:
189
+ logger.debug(line)
190
+
191
+ def _format_event(self, event: StreamEvent) -> str | None:
192
+ """Format a single event into a log line (plain text, no Rich markup).
193
+
194
+ Logs lifecycle + tool boundaries; skips TextChunkEvent to avoid
195
+ cluttering task.log with streaming text chunks.
196
+ """
197
+ if isinstance(event, AgentStartEvent):
198
+ model = f" (model={event.model})" if event.model else ""
199
+ return f"[{event.task_id}] --- Iteration {event.iteration}{model} ---"
200
+
201
+ if isinstance(event, TurnStartEvent):
202
+ model = f" model={event.model}" if event.model else ""
203
+ return f"[{event.task_id}] >>> Turn start: id={event.turn_id}{model}"
204
+
205
+ if isinstance(event, ToolStartEvent):
206
+ params_str = format_payload(event.tool.parameters, max_chars=_MAX_PARAMS_LEN)
207
+ return (
208
+ f"[{event.task_id}] >>> TOOL CALL: {event.tool.tool_name} "
209
+ f"| id={event.tool.tool_id} | params={params_str}"
210
+ )
211
+
212
+ if isinstance(event, ToolEndEvent):
213
+ return (
214
+ f"[{event.task_id}] <<< TOOL RESULT [{event.status.value.upper()}]: "
215
+ f"id={event.tool.tool_id} | {event.tool.result_summary or ''}"
216
+ )
217
+
218
+ if isinstance(event, TextChunkEvent):
219
+ # Skip text chunks to avoid cluttering logs with streaming text
220
+ return None
221
+
222
+ if isinstance(event, TurnEndEvent):
223
+ tok = format_token_usage(event.tokens) if event.tokens is not None else "n/a"
224
+ return f"[{event.task_id}] --- Turn end [{event.status.value}]: {tok} ---"
225
+
226
+ if isinstance(event, AgentEndEvent):
227
+ usage_str = format_token_usage(event.usage)
228
+ line = (
229
+ f"[{event.task_id}] --- Agent complete [{event.status.value}]: "
230
+ f"{len(event.messages)} msgs, {event.duration_seconds:.1f}s, {usage_str} ---"
231
+ )
232
+ if event.max_turns_exhausted:
233
+ line += " (max_turns exhausted)"
234
+ if event.crashed and event.crash_reason:
235
+ line += f"\n[{event.task_id}] reason: {event.crash_reason}"
236
+ error_detail = _format_result_error(event.result_summary)
237
+ if error_detail is not None:
238
+ line += f"\n[{event.task_id}] detail: {error_detail}"
239
+ return line
240
+
241
+ if isinstance(event, CriteriaCheckEvent):
242
+ details = " | ".join(event.details) if event.details else f"{event.passed}/{event.total}"
243
+ return (
244
+ f"[{event.task_id}] Criteria: {event.passed}/{event.total} passed "
245
+ f"(score: {event.weighted_score:.3f}) [{details}]"
246
+ )
247
+
248
+ return None
@@ -0,0 +1,115 @@
1
+ """Line-based wire format for streaming events across a process boundary.
2
+
3
+ Used by the Docker isolation path: the in-container CLI installs a callback
4
+ that writes prefixed lines to stdout; ``DockerRunner`` on the host parses them
5
+ and forwards to the original host callback. Plain log lines without the prefix
6
+ pass through to docker.log unchanged.
7
+
8
+ Each line is ``LINE_PREFIX`` (the RS-framed sentinel ``"\x1ecoder-eval-stream\x1e:"``)
9
+ followed by ``{"cls": <event class name>, "data": <event.model_dump(mode="json")>}``.
10
+ The control-char sentinel (not a plain ``STREAM_EVENT:`` token) avoids collisions
11
+ with ordinary log output. Datetimes are encoded as ISO strings by Pydantic.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import contextlib
17
+ import json
18
+ import logging
19
+
20
+ from coder_eval.streaming.events import (
21
+ AgentEndEvent,
22
+ AgentStartEvent,
23
+ CriteriaCheckEvent,
24
+ StreamEvent,
25
+ TextChunkEvent,
26
+ ToolEndEvent,
27
+ ToolStartEvent,
28
+ TurnEndEvent,
29
+ TurnStartEvent,
30
+ )
31
+
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ # ASCII Record Separator (U+001E) framing the sentinel makes collision
36
+ # with real agent / tool / pytest output effectively impossible: control
37
+ # chars below 0x20 don't appear in normal stdout. A line that starts with
38
+ # this exact byte sequence is a streamed event by construction.
39
+ LINE_PREFIX = "\x1ecoder-eval-stream\x1e:"
40
+
41
+ _EVENT_CLASSES: dict[str, type[StreamEvent]] = {
42
+ cls.__name__: cls
43
+ for cls in [
44
+ AgentStartEvent,
45
+ TurnStartEvent,
46
+ ToolStartEvent,
47
+ ToolEndEvent,
48
+ TextChunkEvent,
49
+ TurnEndEvent,
50
+ AgentEndEvent,
51
+ CriteriaCheckEvent,
52
+ ]
53
+ }
54
+
55
+
56
+ def serialize_event(event: StreamEvent) -> str:
57
+ """Render a single event as a one-line wire string (no trailing newline)."""
58
+ return LINE_PREFIX + json.dumps({"cls": type(event).__name__, "data": event.model_dump(mode="json")})
59
+
60
+
61
+ def has_prefix(line: str) -> bool:
62
+ """Cheap check for whether a line *claims* to be an event.
63
+
64
+ Lets callers distinguish "not an event, log as-is" from "parse
65
+ failed, also log as-is but warn" -- the latter is a wire-format
66
+ bug worth surfacing.
67
+ """
68
+ return line.startswith(LINE_PREFIX)
69
+
70
+
71
+ def deserialize_event(line: str) -> StreamEvent | None:
72
+ """Parse a streamed-event line back into a StreamEvent.
73
+
74
+ Returns ``None`` for any line without the prefix OR a prefixed line
75
+ that fails to parse. The control-char prefix makes false positives
76
+ practically impossible; a malformed prefixed line is logged at WARN
77
+ so the caller can preserve the raw line in docker.log.
78
+ """
79
+ if not line.startswith(LINE_PREFIX):
80
+ return None
81
+ try:
82
+ payload = json.loads(line[len(LINE_PREFIX) :])
83
+ cls_name = payload["cls"]
84
+ data = payload["data"]
85
+ cls = _EVENT_CLASSES.get(cls_name)
86
+ if cls is None:
87
+ logger.warning("Unknown stream event class %r; dropping", cls_name)
88
+ return None
89
+ # Pydantic handles datetime + nested-model reconstruction from the dict.
90
+ return cls.model_validate(data)
91
+ except Exception as exc:
92
+ # Prefix matched but JSON / class / construction failed. Log loud --
93
+ # this is a wire-format bug, not benign noise.
94
+ logger.warning("Failed to deserialize stream event %r: %s", line[:80], exc)
95
+ return None
96
+
97
+
98
+ class StdoutNDJsonCallback:
99
+ """StreamCallback that writes each event as a ``STREAM_EVENT:`` line to stdout.
100
+
101
+ Used inside the Docker container; the host's DockerRunner picks the
102
+ lines back up and forwards them to the host-side callback. Lines are
103
+ flushed immediately so the host sees events in real time.
104
+ """
105
+
106
+ def on_event(self, event: StreamEvent) -> None:
107
+ # Plain print + flush -- the in-container Python is line-buffered
108
+ # under non-tty stdout, so explicit flush matters.
109
+ # BrokenPipeError guard: if the host got SIGKILL'd or docker-kill'd
110
+ # the container mid-run, our stdout pipe peer is gone. Letting
111
+ # BrokenPipeError propagate would crash whatever in-container code
112
+ # path emitted the event (typically the orchestrator's run loop)
113
+ # and prevent task.json from being written for partial results.
114
+ with contextlib.suppress(BrokenPipeError, OSError):
115
+ print(serialize_event(event), flush=True)