coder-eval 0.8.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- coder_eval/.gitattributes +2 -0
- coder_eval/__init__.py +3 -0
- coder_eval/agent.py +302 -0
- coder_eval/agents/__init__.py +41 -0
- coder_eval/agents/_logging.py +89 -0
- coder_eval/agents/antigravity_agent.py +811 -0
- coder_eval/agents/claude_code_agent.py +1701 -0
- coder_eval/agents/codex_agent.py +2055 -0
- coder_eval/agents/noop_agent.py +100 -0
- coder_eval/agents/registry.py +163 -0
- coder_eval/agents/watchdog.py +116 -0
- coder_eval/analysis.py +88 -0
- coder_eval/cli/__init__.py +87 -0
- coder_eval/cli/aggregate_command.py +153 -0
- coder_eval/cli/console.py +7 -0
- coder_eval/cli/evaluate_command.py +164 -0
- coder_eval/cli/plan_command.py +152 -0
- coder_eval/cli/report_command.py +121 -0
- coder_eval/cli/run_command.py +686 -0
- coder_eval/cli/run_helpers.py +137 -0
- coder_eval/cli/run_task_internal_command.py +210 -0
- coder_eval/cli/utils.py +37 -0
- coder_eval/config.py +212 -0
- coder_eval/criteria/__init__.py +163 -0
- coder_eval/criteria/_classification_aggregate.py +106 -0
- coder_eval/criteria/agent_judge.py +425 -0
- coder_eval/criteria/base.py +248 -0
- coder_eval/criteria/classification_match.py +107 -0
- coder_eval/criteria/command_executed.py +181 -0
- coder_eval/criteria/commands_efficiency.py +73 -0
- coder_eval/criteria/file_check.py +119 -0
- coder_eval/criteria/file_contains.py +85 -0
- coder_eval/criteria/file_exists.py +45 -0
- coder_eval/criteria/file_matches_regex.py +86 -0
- coder_eval/criteria/json_check.py +184 -0
- coder_eval/criteria/llm_judge.py +260 -0
- coder_eval/criteria/reference_comparison.py +130 -0
- coder_eval/criteria/run_command.py +220 -0
- coder_eval/criteria/skill_triggered.py +111 -0
- coder_eval/criteria/uipath_eval.py +223 -0
- coder_eval/errors/__init__.py +26 -0
- coder_eval/errors/agent.py +26 -0
- coder_eval/errors/budget.py +28 -0
- coder_eval/errors/categories.py +205 -0
- coder_eval/errors/categorization.py +240 -0
- coder_eval/errors/executor.py +124 -0
- coder_eval/errors/judge.py +12 -0
- coder_eval/errors/retry.py +211 -0
- coder_eval/errors/timeout.py +63 -0
- coder_eval/evaluation/__init__.py +10 -0
- coder_eval/evaluation/checker.py +260 -0
- coder_eval/evaluation/judge_anthropic.py +55 -0
- coder_eval/evaluation/judge_bedrock.py +114 -0
- coder_eval/evaluation/judge_context.py +497 -0
- coder_eval/evaluation/judge_models.py +53 -0
- coder_eval/evaluation/judge_persistence.py +291 -0
- coder_eval/evaluation/judge_usage.py +50 -0
- coder_eval/evaluation/sub_agent.py +231 -0
- coder_eval/evaluation/summaries.py +48 -0
- coder_eval/evaluation/verdict_tool.py +213 -0
- coder_eval/formatting.py +191 -0
- coder_eval/isolation/__init__.py +15 -0
- coder_eval/isolation/docker_runner.py +1253 -0
- coder_eval/logging_config.py +399 -0
- coder_eval/models/__init__.py +337 -0
- coder_eval/models/agent_config.py +373 -0
- coder_eval/models/container_paths.py +26 -0
- coder_eval/models/criteria.py +942 -0
- coder_eval/models/enums.py +121 -0
- coder_eval/models/experiment.py +314 -0
- coder_eval/models/judge.py +90 -0
- coder_eval/models/judge_defaults.py +11 -0
- coder_eval/models/limits.py +89 -0
- coder_eval/models/merge_strategy.py +132 -0
- coder_eval/models/mutations.py +95 -0
- coder_eval/models/results.py +787 -0
- coder_eval/models/routing.py +160 -0
- coder_eval/models/sandbox.py +371 -0
- coder_eval/models/tasks.py +631 -0
- coder_eval/models/telemetry.py +454 -0
- coder_eval/models/templates.py +108 -0
- coder_eval/orchestration/__init__.py +13 -0
- coder_eval/orchestration/batch.py +690 -0
- coder_eval/orchestration/config.py +111 -0
- coder_eval/orchestration/config_merge.py +431 -0
- coder_eval/orchestration/evaluation.py +94 -0
- coder_eval/orchestration/experiment.py +837 -0
- coder_eval/orchestration/overrides.py +206 -0
- coder_eval/orchestration/task_loader.py +437 -0
- coder_eval/orchestrator.py +2078 -0
- coder_eval/path_utils.py +79 -0
- coder_eval/plugins.py +81 -0
- coder_eval/pricing.py +169 -0
- coder_eval/py.typed +0 -0
- coder_eval/reports.py +1066 -0
- coder_eval/reports_experiment.py +761 -0
- coder_eval/reports_html.py +1659 -0
- coder_eval/reports_stats.py +250 -0
- coder_eval/resources/__init__.py +137 -0
- coder_eval/resources/default_experiment.yaml +85 -0
- coder_eval/resources/default_ignore_patterns.yaml +60 -0
- coder_eval/resources/tags.yaml +55 -0
- coder_eval/sandbox.py +1127 -0
- coder_eval/scoring/__init__.py +11 -0
- coder_eval/scoring/ast_similarity.py +38 -0
- coder_eval/scoring/complexity.py +115 -0
- coder_eval/scoring/quality.py +154 -0
- coder_eval/scoring/signature_similarity.py +57 -0
- coder_eval/scoring/similarity.py +103 -0
- coder_eval/scoring/token_similarity.py +44 -0
- coder_eval/simulation/__init__.py +27 -0
- coder_eval/simulation/termination.py +88 -0
- coder_eval/simulation/user_simulator.py +324 -0
- coder_eval/streaming/__init__.py +44 -0
- coder_eval/streaming/callbacks.py +57 -0
- coder_eval/streaming/collector.py +193 -0
- coder_eval/streaming/events.py +198 -0
- coder_eval/streaming/renderers.py +248 -0
- coder_eval/streaming/wire.py +115 -0
- coder_eval/telemetry.py +407 -0
- coder_eval/utils.py +517 -0
- coder_eval-0.8.2.dist-info/METADATA +242 -0
- coder_eval-0.8.2.dist-info/RECORD +127 -0
- coder_eval-0.8.2.dist-info/WHEEL +4 -0
- coder_eval-0.8.2.dist-info/entry_points.txt +5 -0
- coder_eval-0.8.2.dist-info/licenses/LICENSE +201 -0
- coder_eval-0.8.2.dist-info/licenses/NOTICE +18 -0
|
@@ -0,0 +1,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)
|