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,240 @@
|
|
|
1
|
+
"""Error categorization logic using pattern matching.
|
|
2
|
+
|
|
3
|
+
This module provides the categorize_error function that analyzes exceptions
|
|
4
|
+
and maps them to appropriate ErrorCategory values for retry logic.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .agent import AgentConfigError, AgentCrashError
|
|
11
|
+
from .budget import BudgetExceededError
|
|
12
|
+
from .categories import ErrorCategory
|
|
13
|
+
from .timeout import EvaluationTimeoutError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _categorize_by_exception_type(error: Exception, component: str) -> ErrorCategory | None:
|
|
20
|
+
"""Typed-exception checks (most reliable), in precedence order.
|
|
21
|
+
|
|
22
|
+
Returns None when no typed check matches, so the caller falls through to
|
|
23
|
+
string-pattern matching. ``AgentCrashError`` is deliberately NOT checked here:
|
|
24
|
+
it is the last resort (see ``categorize_error``), after string patterns get a
|
|
25
|
+
chance to recognise auth / rate-limit / billing / content-filter signatures
|
|
26
|
+
stamped into the message. The component-gated branches also return ``None``
|
|
27
|
+
when their component doesn't match, so non-agent errors fall through to the
|
|
28
|
+
component group (e.g. a sandbox TimeoutError reaches the sandbox categories
|
|
29
|
+
with their designed retry behavior instead of dead-ending at ``UNKNOWN``).
|
|
30
|
+
"""
|
|
31
|
+
# Check custom timeout exceptions first (before generic TimeoutError)
|
|
32
|
+
if isinstance(error, EvaluationTimeoutError):
|
|
33
|
+
return ErrorCategory.AGENT_TIMEOUT
|
|
34
|
+
|
|
35
|
+
# RunLimits budget breach — typed, distinct from time-based exceptions.
|
|
36
|
+
if isinstance(error, BudgetExceededError):
|
|
37
|
+
return ErrorCategory.BUDGET_EXCEEDED
|
|
38
|
+
|
|
39
|
+
# AgentConfigError subclasses RuntimeError, so this typed check must precede
|
|
40
|
+
# any string-pattern fallback that could re-categorise a missing-prerequisite
|
|
41
|
+
# message as the retryable AGENT_API_ERROR.
|
|
42
|
+
if isinstance(error, AgentConfigError):
|
|
43
|
+
return ErrorCategory.AGENT_CONFIG_ERROR
|
|
44
|
+
|
|
45
|
+
if isinstance(error, TimeoutError):
|
|
46
|
+
if component == "agent":
|
|
47
|
+
return ErrorCategory.AGENT_TIMEOUT
|
|
48
|
+
return None # fall through: let the component group categorize it
|
|
49
|
+
|
|
50
|
+
if isinstance(error, FileNotFoundError):
|
|
51
|
+
if "task" in str(error).lower() or "yaml" in str(error).lower():
|
|
52
|
+
return ErrorCategory.TASK_NOT_FOUND
|
|
53
|
+
return None # fall through: let the component group categorize it
|
|
54
|
+
|
|
55
|
+
if isinstance(error, MemoryError):
|
|
56
|
+
return ErrorCategory.OUT_OF_MEMORY
|
|
57
|
+
|
|
58
|
+
# Check for known SDK exceptions (if available)
|
|
59
|
+
try:
|
|
60
|
+
from anthropic import AuthenticationError, RateLimitError
|
|
61
|
+
|
|
62
|
+
if isinstance(error, RateLimitError):
|
|
63
|
+
return ErrorCategory.AGENT_RATE_LIMIT
|
|
64
|
+
if isinstance(error, AuthenticationError):
|
|
65
|
+
return ErrorCategory.AGENT_AUTH_ERROR
|
|
66
|
+
except ImportError:
|
|
67
|
+
pass # anthropic package not installed; fall through to string matching
|
|
68
|
+
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _categorize_by_message(error_str: str, component: str) -> ErrorCategory | None:
|
|
73
|
+
"""String-pattern matching on the already-lowercased error message.
|
|
74
|
+
|
|
75
|
+
``error_str`` is the caller's ``str(error).lower()``. Patterns run in precedence
|
|
76
|
+
order; returns None when no pattern matches (fall through to component
|
|
77
|
+
categorization). The component-gated branches also return ``None`` for
|
|
78
|
+
non-agent components, so e.g. a sandbox pip failure mentioning "connection"
|
|
79
|
+
reaches ``PACKAGE_INSTALL_ERROR`` (retryable) instead of ``UNKNOWN``.
|
|
80
|
+
"""
|
|
81
|
+
# Authentication errors
|
|
82
|
+
if any(pat in error_str for pat in ["authentication", "unauthorized", "invalid api key", "401"]):
|
|
83
|
+
return ErrorCategory.AGENT_AUTH_ERROR
|
|
84
|
+
|
|
85
|
+
# Billing/credit errors (NOT retryable - retrying wastes time)
|
|
86
|
+
# NOTE: Broad patterns like "insufficient" and "credit" are intentional. We prefer
|
|
87
|
+
# false positives (skipping retry on a non-billing error) over false negatives
|
|
88
|
+
# (wasting retries on a billing error that will never succeed).
|
|
89
|
+
if any(
|
|
90
|
+
pat in error_str
|
|
91
|
+
for pat in ["credit", "billing", "payment", "insufficient", "402", "quota exceeded", "spending limit"]
|
|
92
|
+
):
|
|
93
|
+
return ErrorCategory.AGENT_BILLING_ERROR
|
|
94
|
+
|
|
95
|
+
# Rate limiting
|
|
96
|
+
if any(pat in error_str for pat in ["rate limit", "429", "ratelimit", "too many requests"]):
|
|
97
|
+
return ErrorCategory.AGENT_RATE_LIMIT
|
|
98
|
+
|
|
99
|
+
# Timeouts
|
|
100
|
+
if "timeout" in error_str:
|
|
101
|
+
if component == "agent":
|
|
102
|
+
return ErrorCategory.AGENT_TIMEOUT
|
|
103
|
+
return None # fall through: let the component group categorize it
|
|
104
|
+
|
|
105
|
+
# Content filtering (Bedrock guardrails) — NOT retryable, same output will be blocked again
|
|
106
|
+
if any(pat in error_str for pat in ["content filtering policy", "content filter", "guardrail"]):
|
|
107
|
+
return ErrorCategory.AGENT_INVALID_OUTPUT
|
|
108
|
+
|
|
109
|
+
# API/Network errors
|
|
110
|
+
if any(pat in error_str for pat in ["api error", "connection", "network", "502", "503", "504"]):
|
|
111
|
+
if component == "agent":
|
|
112
|
+
return ErrorCategory.AGENT_API_ERROR
|
|
113
|
+
return None # fall through: let the component group categorize it
|
|
114
|
+
|
|
115
|
+
# Disk errors
|
|
116
|
+
if any(pat in error_str for pat in ["disk", "no space", "enospc"]):
|
|
117
|
+
return ErrorCategory.DISK_FULL
|
|
118
|
+
|
|
119
|
+
# Memory errors
|
|
120
|
+
if any(pat in error_str for pat in ["memory", "oom", "out of memory"]):
|
|
121
|
+
return ErrorCategory.OUT_OF_MEMORY
|
|
122
|
+
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _categorize_by_component(error: Exception, error_str: str, component: str) -> ErrorCategory | None:
|
|
127
|
+
"""Component-specific categorization for callers that supplied a component hint.
|
|
128
|
+
|
|
129
|
+
Returns None **only** when ``component`` is not one of sandbox/agent/evaluator/task,
|
|
130
|
+
so the main function reaches the final AgentCrashError / UNKNOWN fallback.
|
|
131
|
+
"""
|
|
132
|
+
if component == "sandbox":
|
|
133
|
+
if "venv" in error_str or "virtualenv" in error_str:
|
|
134
|
+
return ErrorCategory.VENV_CREATION_ERROR
|
|
135
|
+
if "install" in error_str or "pip" in error_str or "package" in error_str:
|
|
136
|
+
return ErrorCategory.PACKAGE_INSTALL_ERROR
|
|
137
|
+
if "git" in error_str or "clone" in error_str:
|
|
138
|
+
return ErrorCategory.GIT_CLONE_ERROR
|
|
139
|
+
if "template" in error_str or "copy" in error_str:
|
|
140
|
+
return ErrorCategory.TEMPLATE_COPY_ERROR
|
|
141
|
+
return ErrorCategory.SANDBOX_SETUP_ERROR
|
|
142
|
+
|
|
143
|
+
if component == "agent":
|
|
144
|
+
# Substring heuristics for plain RuntimeErrors that mention a crash
|
|
145
|
+
# but aren't typed as AgentCrashError. The typed-AgentCrashError
|
|
146
|
+
# check below catches the agent's wrapped exceptions; this branch
|
|
147
|
+
# only fires for bare exceptions whose message happens to describe
|
|
148
|
+
# a crash.
|
|
149
|
+
if (
|
|
150
|
+
"crash" in error_str
|
|
151
|
+
or "killed" in error_str
|
|
152
|
+
or "cli process failed (exit code" in error_str
|
|
153
|
+
or "command failed with exit code" in error_str
|
|
154
|
+
):
|
|
155
|
+
return ErrorCategory.AGENT_CRASH
|
|
156
|
+
if "invalid" in error_str or "malformed" in error_str:
|
|
157
|
+
return ErrorCategory.AGENT_INVALID_OUTPUT
|
|
158
|
+
|
|
159
|
+
# Typed agent-crash exception is the LAST agent-side resort: by this
|
|
160
|
+
# point pattern matching has had a chance to recognise auth / rate-
|
|
161
|
+
# limit / billing / content-filter / api-network signatures stamped
|
|
162
|
+
# into the message, so anything still typed as AgentCrashError here
|
|
163
|
+
# is a genuinely unexpected failure that the user wants retried.
|
|
164
|
+
if isinstance(error, AgentCrashError):
|
|
165
|
+
return ErrorCategory.AGENT_CRASH
|
|
166
|
+
|
|
167
|
+
# Default to API error for unknown agent failures (more likely to be retryable)
|
|
168
|
+
return ErrorCategory.AGENT_API_ERROR
|
|
169
|
+
|
|
170
|
+
if component == "evaluator":
|
|
171
|
+
return ErrorCategory.CRITERION_CHECK_ERROR
|
|
172
|
+
|
|
173
|
+
if component == "task":
|
|
174
|
+
if "invalid" in error_str or "malformed" in error_str or "validation" in error_str:
|
|
175
|
+
return ErrorCategory.TASK_INVALID
|
|
176
|
+
return ErrorCategory.TASK_NOT_FOUND
|
|
177
|
+
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def categorize_error(
|
|
182
|
+
error: Exception,
|
|
183
|
+
context: dict[str, Any],
|
|
184
|
+
hint: ErrorCategory | None = None,
|
|
185
|
+
) -> ErrorCategory:
|
|
186
|
+
"""Categorize an exception into an ErrorCategory.
|
|
187
|
+
|
|
188
|
+
Uses pattern matching on error message and exception type,
|
|
189
|
+
plus context hints (component name) to determine category.
|
|
190
|
+
|
|
191
|
+
Prioritizes:
|
|
192
|
+
1. Explicit hint (if provided by caller who knows the context)
|
|
193
|
+
2. Exception type checking (most reliable)
|
|
194
|
+
3. Known SDK exceptions (if available)
|
|
195
|
+
4. String pattern matching (fallback)
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
error: The exception to categorize
|
|
199
|
+
context: Additional context dict with keys:
|
|
200
|
+
- component: "agent", "sandbox", "evaluator", etc.
|
|
201
|
+
- task_id: Task identifier (for logging)
|
|
202
|
+
hint: Optional explicit category (when caller knows the type)
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
ErrorCategory enum value
|
|
206
|
+
|
|
207
|
+
Example:
|
|
208
|
+
>>> categorize_error(TimeoutError(), {"component": "agent"})
|
|
209
|
+
<ErrorCategory.AGENT_TIMEOUT: 'agent_timeout'>
|
|
210
|
+
>>> categorize_error(Exception("Rate limit"), {"component": "agent"})
|
|
211
|
+
<ErrorCategory.AGENT_RATE_LIMIT: 'agent_rate_limit'>
|
|
212
|
+
"""
|
|
213
|
+
# Use hint if provided (caller knows best)
|
|
214
|
+
if hint:
|
|
215
|
+
return hint
|
|
216
|
+
|
|
217
|
+
component = context.get("component", "")
|
|
218
|
+
|
|
219
|
+
# Precedence: typed-exception group → message-string group → component group.
|
|
220
|
+
# A helper returning None means "no match in my group, fall through" — including
|
|
221
|
+
# the component-gated timeout/network arms for non-agent components, so a
|
|
222
|
+
# sandbox failure reaches the sandbox categories (and their retries) below.
|
|
223
|
+
if (category := _categorize_by_exception_type(error, component)) is not None:
|
|
224
|
+
return category
|
|
225
|
+
|
|
226
|
+
error_str = str(error).lower()
|
|
227
|
+
if (category := _categorize_by_message(error_str, component)) is not None:
|
|
228
|
+
return category
|
|
229
|
+
|
|
230
|
+
if (category := _categorize_by_component(error, error_str, component)) is not None:
|
|
231
|
+
return category
|
|
232
|
+
|
|
233
|
+
# Final typed-AgentCrashError fallback for callers without a component hint:
|
|
234
|
+
# nothing more specific matched, treat as a genuinely unexpected crash.
|
|
235
|
+
if isinstance(error, AgentCrashError):
|
|
236
|
+
return ErrorCategory.AGENT_CRASH
|
|
237
|
+
|
|
238
|
+
# Default to unknown
|
|
239
|
+
logger.warning(f"Could not categorize error: {error} (component={component})")
|
|
240
|
+
return ErrorCategory.UNKNOWN
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Retry executor - main retry orchestration logic."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from collections.abc import Awaitable, Callable
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .categories import RETRY_CONFIG, RetryConfig
|
|
9
|
+
from .categorization import categorize_error
|
|
10
|
+
from .retry import get_error_tip, get_retry_delay, should_retry
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def execute_with_retry(
|
|
17
|
+
operation: Callable[[], Awaitable[Any]],
|
|
18
|
+
operation_name: str,
|
|
19
|
+
context: dict[str, Any],
|
|
20
|
+
max_attempts: int | None = None,
|
|
21
|
+
on_attempt_error: Callable[[Exception, int], Awaitable[None]] | None = None,
|
|
22
|
+
) -> Any:
|
|
23
|
+
"""Execute an operation with automatic retry on transient errors.
|
|
24
|
+
|
|
25
|
+
This is the core retry mechanism. It wraps any async operation and:
|
|
26
|
+
1. Executes the operation
|
|
27
|
+
2. Catches exceptions
|
|
28
|
+
3. Categorizes the error
|
|
29
|
+
4. Retries if eligible (with exponential backoff + jitter)
|
|
30
|
+
5. Re-raises after exhausting retries
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
operation: Async callable to execute (no arguments, use closures/lambdas)
|
|
34
|
+
operation_name: Human-readable operation name for logging
|
|
35
|
+
context: Context dict with:
|
|
36
|
+
- task_id: Task identifier (required)
|
|
37
|
+
- component: Component name (optional but recommended)
|
|
38
|
+
- agent_name: Agent name (optional)
|
|
39
|
+
max_attempts: Override max attempts (defaults to 10 as safety limit)
|
|
40
|
+
on_attempt_error: Async ``(exception, zero_indexed_attempt) -> None`` callback
|
|
41
|
+
invoked after every failed attempt (including the final non-retryable one),
|
|
42
|
+
for draining ``agent.pending_turn`` and calling ``agent.discard_pending_turn()``.
|
|
43
|
+
Callback exceptions are logged and swallowed so they cannot mask the original.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
Result from operation
|
|
47
|
+
|
|
48
|
+
Raises:
|
|
49
|
+
Last exception if all retries exhausted
|
|
50
|
+
|
|
51
|
+
Example:
|
|
52
|
+
>>> async def flaky_api_call():
|
|
53
|
+
... return await agent.communicate(prompt)
|
|
54
|
+
>>>
|
|
55
|
+
>>> result = await execute_with_retry(
|
|
56
|
+
... operation=flaky_api_call,
|
|
57
|
+
... operation_name="Agent communication",
|
|
58
|
+
... context={"task_id": "task-001", "component": "agent"},
|
|
59
|
+
... )
|
|
60
|
+
"""
|
|
61
|
+
task_id = context.get("task_id", "unknown")
|
|
62
|
+
last_error = None
|
|
63
|
+
|
|
64
|
+
# Determine max attempts (safety limit)
|
|
65
|
+
if max_attempts is None:
|
|
66
|
+
max_attempts = 10
|
|
67
|
+
|
|
68
|
+
for attempt in range(max_attempts):
|
|
69
|
+
try:
|
|
70
|
+
# Execute operation
|
|
71
|
+
return await operation()
|
|
72
|
+
|
|
73
|
+
except asyncio.CancelledError:
|
|
74
|
+
# Re-raise cancellation immediately - not an error to retry
|
|
75
|
+
raise
|
|
76
|
+
|
|
77
|
+
except Exception as e:
|
|
78
|
+
last_error = e
|
|
79
|
+
|
|
80
|
+
# Fire callback before the retry decision so partial telemetry
|
|
81
|
+
# is captured even on the final non-retryable attempt.
|
|
82
|
+
if on_attempt_error is not None:
|
|
83
|
+
try:
|
|
84
|
+
await on_attempt_error(e, attempt)
|
|
85
|
+
except Exception:
|
|
86
|
+
logger.exception(
|
|
87
|
+
"[%s] on_attempt_error callback raised for %s (attempt %d); ignoring",
|
|
88
|
+
task_id,
|
|
89
|
+
operation_name,
|
|
90
|
+
attempt + 1,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# Categorize error
|
|
94
|
+
category = categorize_error(e, context)
|
|
95
|
+
config = RETRY_CONFIG.get(category, RetryConfig())
|
|
96
|
+
|
|
97
|
+
# Check if we should retry (handles both non-retryable categories and exhausted attempts)
|
|
98
|
+
if not should_retry(category, attempt):
|
|
99
|
+
if config.max_retries == 0:
|
|
100
|
+
logger.error(f"[{task_id}] {operation_name} failed (non-retryable): {category.value} - {e}")
|
|
101
|
+
else:
|
|
102
|
+
logger.error(
|
|
103
|
+
f"[{task_id}] {operation_name} failed after {attempt + 1} attempts: {category.value} - {e}"
|
|
104
|
+
)
|
|
105
|
+
raise
|
|
106
|
+
|
|
107
|
+
# Calculate backoff with jitter
|
|
108
|
+
delay = get_retry_delay(category, attempt)
|
|
109
|
+
|
|
110
|
+
# Enhanced warning with actionable tip
|
|
111
|
+
tip = get_error_tip(category)
|
|
112
|
+
message = (
|
|
113
|
+
f"[{task_id}] {operation_name} failed (attempt {attempt + 1}/{config.max_retries + 1}): "
|
|
114
|
+
f"{category.value} - {e}. Retrying in {delay:.1f}s...\n 💡 Tip: {tip}"
|
|
115
|
+
)
|
|
116
|
+
logger.warning(message)
|
|
117
|
+
|
|
118
|
+
# Async sleep (non-blocking)
|
|
119
|
+
await asyncio.sleep(delay)
|
|
120
|
+
|
|
121
|
+
# Should never reach here, but just in case
|
|
122
|
+
if last_error:
|
|
123
|
+
raise last_error
|
|
124
|
+
raise RuntimeError(f"Unexpected retry loop exit for {operation_name}")
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Typed error for judge-side infrastructure failures."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class JudgeInfrastructureError(RuntimeError):
|
|
5
|
+
"""Judge-side infrastructure failure (transport error, throttle, 5xx, auth)
|
|
6
|
+
after bounded retries.
|
|
7
|
+
|
|
8
|
+
Deliberately NOT converted to a scored-0.0 ``CriterionResult``:
|
|
9
|
+
``handle_criterion_errors`` and ``SuccessChecker._check_single`` re-raise it
|
|
10
|
+
so the orchestrator lands the row as ``FinalStatus.ERROR`` — a judge outage
|
|
11
|
+
must be distinguishable from genuine agent failure in pass-rate metrics.
|
|
12
|
+
"""
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Retry logic with exponential backoff and error context management.
|
|
2
|
+
|
|
3
|
+
This module provides retry functionality, error context creation, and helper
|
|
4
|
+
utilities for production-grade error handling.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import random
|
|
9
|
+
import traceback
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .categories import ERROR_TIPS, RETRY_CONFIG, ErrorCategory, ErrorContext, RetryConfig
|
|
14
|
+
from .categorization import categorize_error
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def should_retry(category: ErrorCategory, attempt: int) -> bool:
|
|
21
|
+
"""Determine if an error should be retried.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
category: Error category
|
|
25
|
+
attempt: Current attempt number (0-indexed)
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
True if retry should be attempted
|
|
29
|
+
"""
|
|
30
|
+
config = RETRY_CONFIG.get(category, RetryConfig())
|
|
31
|
+
return attempt < config.max_retries
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def compute_backoff(config: RetryConfig, attempt: int) -> float:
|
|
35
|
+
"""Calculate delay with exponential backoff and jitter.
|
|
36
|
+
|
|
37
|
+
Formula: (initial_delay * backoff_multiplier^attempt) + jitter
|
|
38
|
+
Jitter: Random value between 0 and 25% of base delay
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
config: Retry configuration (initial delay, multiplier)
|
|
42
|
+
attempt: Current attempt number (0-indexed)
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
Delay in seconds
|
|
46
|
+
"""
|
|
47
|
+
base_delay = config.initial_delay * (config.backoff_multiplier**attempt)
|
|
48
|
+
|
|
49
|
+
# Add jitter: up to 25% of base delay to prevent thundering herd
|
|
50
|
+
jitter = random.uniform(0, base_delay * 0.25)
|
|
51
|
+
|
|
52
|
+
return base_delay + jitter
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def get_retry_delay(category: ErrorCategory, attempt: int) -> float:
|
|
56
|
+
"""Calculate delay before retry with exponential backoff and jitter.
|
|
57
|
+
|
|
58
|
+
Convenience wrapper around ``compute_backoff`` that looks up the
|
|
59
|
+
``RetryConfig`` for the given error category.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
category: Error category
|
|
63
|
+
attempt: Current attempt number (0-indexed)
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
Delay in seconds
|
|
67
|
+
|
|
68
|
+
Example:
|
|
69
|
+
>>> get_retry_delay(ErrorCategory.AGENT_API_ERROR, 0) # doctest: +SKIP
|
|
70
|
+
5.2 # 5.0 base + 0.2 jitter
|
|
71
|
+
>>> get_retry_delay(ErrorCategory.AGENT_API_ERROR, 1) # doctest: +SKIP
|
|
72
|
+
10.8 # 10.0 base + 0.8 jitter
|
|
73
|
+
"""
|
|
74
|
+
config = RETRY_CONFIG.get(category, RetryConfig())
|
|
75
|
+
return compute_backoff(config, attempt)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def get_error_tip(category: ErrorCategory) -> str:
|
|
79
|
+
"""Get actionable tip for error category.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
category: Error category
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
Actionable tip string
|
|
86
|
+
"""
|
|
87
|
+
return ERROR_TIPS.get(category, "Check logs for details or run with --verbose for more information.")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def truncate_log(log: str, max_chars: int = 1000) -> str:
|
|
91
|
+
"""Truncate log preserving both start and end.
|
|
92
|
+
|
|
93
|
+
Keeps first 40% and last 60% of the log instead of just the end.
|
|
94
|
+
This preserves important startup information while still showing
|
|
95
|
+
the final error state.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
log: Log string to truncate
|
|
99
|
+
max_chars: Maximum characters to keep (default: 1000)
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Truncated log string with separator if truncated
|
|
103
|
+
|
|
104
|
+
Example:
|
|
105
|
+
>>> truncate_log("x" * 100, max_chars=100)
|
|
106
|
+
'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
|
|
107
|
+
>>> len(truncate_log("x" * 2000, max_chars=1000))
|
|
108
|
+
1000
|
|
109
|
+
"""
|
|
110
|
+
if len(log) <= max_chars:
|
|
111
|
+
return log
|
|
112
|
+
|
|
113
|
+
# Keep first 40% and last 60% with separator
|
|
114
|
+
separator = "\n\n... [truncated] ...\n\n"
|
|
115
|
+
head_size = int(max_chars * 0.4)
|
|
116
|
+
tail_size = max(0, max_chars - head_size - len(separator))
|
|
117
|
+
|
|
118
|
+
return log[:head_size] + separator + log[-tail_size:]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def error_context_to_dict(ctx: ErrorContext) -> dict[str, Any]:
|
|
122
|
+
"""Convert ErrorContext to dict for EvaluationResult.error_details.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
ctx: ErrorContext instance
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
Dict representation of error context with error_tip added
|
|
129
|
+
"""
|
|
130
|
+
result = ctx.model_dump()
|
|
131
|
+
# Add error tip for this error category
|
|
132
|
+
category = ErrorCategory(ctx.error_category)
|
|
133
|
+
result["error_tip"] = get_error_tip(category)
|
|
134
|
+
return result
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def create_error_context(
|
|
138
|
+
error: Exception,
|
|
139
|
+
task_id: str,
|
|
140
|
+
attempt: int,
|
|
141
|
+
component: str | None = None,
|
|
142
|
+
agent_name: str | None = None,
|
|
143
|
+
**kwargs: Any,
|
|
144
|
+
) -> dict[str, Any]:
|
|
145
|
+
"""Create comprehensive error context for reporting.
|
|
146
|
+
|
|
147
|
+
Internally creates a type-safe ErrorContext model, then converts to dict
|
|
148
|
+
for backward compatibility with EvaluationResult.error_details.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
error: The exception that occurred
|
|
152
|
+
task_id: Task identifier
|
|
153
|
+
attempt: Attempt number (1-indexed)
|
|
154
|
+
component: Component that failed (agent/sandbox/evaluator)
|
|
155
|
+
agent_name: Agent name (optional)
|
|
156
|
+
**kwargs: Additional context fields:
|
|
157
|
+
- disk_usage_gb: Disk usage in GB
|
|
158
|
+
- memory_usage_gb: Memory usage in GB
|
|
159
|
+
- agent_stdout: Agent stdout (will be truncated)
|
|
160
|
+
- agent_stderr: Agent stderr (will be truncated)
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
Dict with error context fields (from ErrorContext.model_dump())
|
|
164
|
+
|
|
165
|
+
Example:
|
|
166
|
+
>>> ctx = create_error_context(
|
|
167
|
+
... error=ValueError("test"),
|
|
168
|
+
... task_id="task-001",
|
|
169
|
+
... attempt=1,
|
|
170
|
+
... component="agent"
|
|
171
|
+
... )
|
|
172
|
+
>>> ctx["task_id"]
|
|
173
|
+
'task-001'
|
|
174
|
+
>>> ctx["attempt_number"]
|
|
175
|
+
1
|
|
176
|
+
"""
|
|
177
|
+
# Categorize error
|
|
178
|
+
category = categorize_error(error, {"component": component, "task_id": task_id})
|
|
179
|
+
|
|
180
|
+
# Truncate logs (improved: keeps start + end)
|
|
181
|
+
agent_stdout = kwargs.get("agent_stdout")
|
|
182
|
+
if agent_stdout:
|
|
183
|
+
agent_stdout = truncate_log(agent_stdout)
|
|
184
|
+
|
|
185
|
+
agent_stderr = kwargs.get("agent_stderr")
|
|
186
|
+
if agent_stderr:
|
|
187
|
+
agent_stderr = truncate_log(agent_stderr)
|
|
188
|
+
|
|
189
|
+
# Convert to 0-indexed attempt (defensive: prevent negative indexing)
|
|
190
|
+
attempt_index = max(attempt - 1, 0)
|
|
191
|
+
|
|
192
|
+
# Create type-safe ErrorContext model
|
|
193
|
+
ctx = ErrorContext(
|
|
194
|
+
error_category=category.value,
|
|
195
|
+
error_message=str(error),
|
|
196
|
+
stack_trace=traceback.format_exc(),
|
|
197
|
+
task_id=task_id,
|
|
198
|
+
component=component,
|
|
199
|
+
agent_name=agent_name,
|
|
200
|
+
attempt_number=attempt,
|
|
201
|
+
is_retryable=should_retry(category, attempt_index),
|
|
202
|
+
retry_delay_seconds=get_retry_delay(category, attempt_index) if should_retry(category, attempt_index) else 0.0,
|
|
203
|
+
disk_usage_gb=kwargs.get("disk_usage_gb"),
|
|
204
|
+
memory_usage_gb=kwargs.get("memory_usage_gb"),
|
|
205
|
+
agent_stdout=agent_stdout,
|
|
206
|
+
agent_stderr=agent_stderr,
|
|
207
|
+
timestamp=datetime.now().isoformat(),
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
# Return dict for backward compatibility
|
|
211
|
+
return error_context_to_dict(ctx)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Timeout exceptions for evaluation lifecycle."""
|
|
2
|
+
|
|
3
|
+
from .agent import format_timeout_reason
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class EvaluationTimeoutError(Exception):
|
|
7
|
+
"""Base timeout error for evaluation lifecycle.
|
|
8
|
+
|
|
9
|
+
Wraps asyncio.TimeoutError with structured context about
|
|
10
|
+
what timed out and where.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
message: str,
|
|
16
|
+
*,
|
|
17
|
+
timeout_seconds: float,
|
|
18
|
+
layer: str, # "turn" | "task"
|
|
19
|
+
task_id: str | None = None,
|
|
20
|
+
iteration: int | None = None,
|
|
21
|
+
elapsed_seconds: float | None = None,
|
|
22
|
+
):
|
|
23
|
+
super().__init__(message)
|
|
24
|
+
self.timeout_seconds = timeout_seconds
|
|
25
|
+
self.layer = layer
|
|
26
|
+
self.task_id = task_id
|
|
27
|
+
self.iteration = iteration
|
|
28
|
+
self.elapsed_seconds = elapsed_seconds
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TurnTimeoutError(EvaluationTimeoutError):
|
|
32
|
+
"""Agent turn (communicate) exceeded its time limit."""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
timeout_seconds: float,
|
|
37
|
+
*,
|
|
38
|
+
task_id: str | None = None,
|
|
39
|
+
iteration: int | None = None,
|
|
40
|
+
):
|
|
41
|
+
message = format_timeout_reason(timeout_seconds)
|
|
42
|
+
if iteration is not None:
|
|
43
|
+
message = f"{message} (iteration {iteration})"
|
|
44
|
+
super().__init__(
|
|
45
|
+
message,
|
|
46
|
+
timeout_seconds=timeout_seconds,
|
|
47
|
+
layer="turn",
|
|
48
|
+
task_id=task_id,
|
|
49
|
+
iteration=iteration,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class TaskTimeoutError(EvaluationTimeoutError):
|
|
54
|
+
"""Overall task evaluation loop exceeded its time limit."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, timeout_seconds: float, *, task_id: str | None = None, elapsed_seconds: float | None = None):
|
|
57
|
+
super().__init__(
|
|
58
|
+
f"Task timed out after {timeout_seconds}s",
|
|
59
|
+
timeout_seconds=timeout_seconds,
|
|
60
|
+
layer="task",
|
|
61
|
+
task_id=task_id,
|
|
62
|
+
elapsed_seconds=elapsed_seconds,
|
|
63
|
+
)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Evaluation components for checking success criteria and providing LLM feedback.
|
|
2
|
+
|
|
3
|
+
This package contains:
|
|
4
|
+
- checker: Success criterion validation with continuous scoring (0.0-1.0)
|
|
5
|
+
- summaries: summarize_commands helper shared by orchestrator + llm_judge
|
|
6
|
+
|
|
7
|
+
NO re-exports - use explicit imports:
|
|
8
|
+
from coder_eval.evaluation.checker import SuccessChecker
|
|
9
|
+
from coder_eval.evaluation.summaries import summarize_commands
|
|
10
|
+
"""
|