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,111 @@
1
+ """Skill-triggered criterion checker: did the agent engage the target skill?
2
+
3
+ Agent-agnostic. Claude Code engages a skill via an explicit ``Skill`` tool call;
4
+ Codex has no such tool — it auto-discovers skills under ``.agents/skills/`` and
5
+ engages one by reading its ``SKILL.md`` / references off disk via shell. Both
6
+ signals are detected here so the criterion scores identically across agents.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ from typing import TYPE_CHECKING
13
+
14
+ from coder_eval.criteria._classification_aggregate import overlay_classification_metrics
15
+ from coder_eval.criteria.base import BaseCriterion, register_criterion
16
+ from coder_eval.models import (
17
+ ClassificationCriterionResult,
18
+ CriterionAggregate,
19
+ CriterionResult,
20
+ SkillTriggeredCriterion,
21
+ )
22
+
23
+
24
+ if TYPE_CHECKING:
25
+ from coder_eval.criteria.base import CheckContext
26
+ from coder_eval.models.results import TurnRecord
27
+ from coder_eval.models.telemetry import CommandTelemetry
28
+ from coder_eval.sandbox import Sandbox
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ _YES = "yes"
33
+ _NO = "no"
34
+
35
+
36
+ def _engaged_skill(cmd: CommandTelemetry, skill_name: str) -> bool:
37
+ """True when one command engaged ``skill_name`` — agent-agnostically.
38
+
39
+ Claude: an explicit ``Skill`` tool call carries the skill in
40
+ ``parameters['skill']`` (optionally namespaced, e.g. ``plugin:uipath-agents``).
41
+
42
+ Codex (and any non-Claude agent): no ``Skill`` tool exists, so the skill is
43
+ engaged by reading its files off disk via shell. Both the repo layout
44
+ (``.../skills/<skill_name>/...``) and the sandbox symlink
45
+ (``.agents/skills/<skill_name>/...``) contain the substring
46
+ ``skills/<skill_name>/``, which appears in the recorded command string
47
+ (Bash ``parameters['command']``) or a file-path parameter. The trailing
48
+ slash prevents prefix collisions (``uipath-agents`` vs ``uipath-agents-foo``).
49
+ """
50
+ if cmd.tool_name == "Skill" and cmd.parameters.get("skill", "").split(":")[-1] == skill_name:
51
+ return True
52
+ needle = f"skills/{skill_name}/"
53
+ return any(isinstance(v, str) and needle in v for v in cmd.parameters.values())
54
+
55
+
56
+ @register_criterion
57
+ class SkillTriggeredChecker(BaseCriterion[SkillTriggeredCriterion]):
58
+ """Binary classifier: observed='yes' when the agent engaged the target skill.
59
+
60
+ Returns a ``ClassificationCriterionResult`` so the suite aggregator can
61
+ compute accuracy / recall / F1 / confusion matrix across all rows.
62
+ """
63
+
64
+ criterion_type = "skill_triggered"
65
+
66
+ def _check_impl(
67
+ self,
68
+ criterion: SkillTriggeredCriterion,
69
+ sandbox: Sandbox,
70
+ reference_code: str | None = None,
71
+ *,
72
+ turn_records: list[TurnRecord] | None = None,
73
+ context: CheckContext | None = None,
74
+ ) -> CriterionResult:
75
+ if turn_records is None:
76
+ return CriterionResult(
77
+ criterion_type=criterion.type,
78
+ description=criterion.description,
79
+ score=0.0,
80
+ details="No turn records available",
81
+ error="turn_records not provided to checker",
82
+ )
83
+
84
+ triggered: bool = any(
85
+ _engaged_skill(cmd, criterion.skill_name) for turn in turn_records for cmd in turn.commands
86
+ )
87
+ expected_yes: bool = criterion.expected_skill == criterion.skill_name
88
+ score = 1.0 if triggered == expected_yes else 0.0
89
+ observed = _YES if triggered else _NO
90
+ expected = _YES if expected_yes else _NO
91
+
92
+ filt = f" (skill_name={criterion.skill_name!r})"
93
+ return ClassificationCriterionResult(
94
+ criterion_type=criterion.type,
95
+ description=criterion.description,
96
+ score=score,
97
+ details=f"observed={observed!r}, expected={expected!r}{filt}",
98
+ observed_label=observed,
99
+ expected_label=expected,
100
+ )
101
+
102
+ def aggregate(
103
+ self,
104
+ criterion: SkillTriggeredCriterion,
105
+ per_row_results: list[CriterionResult],
106
+ ) -> CriterionAggregate | None:
107
+ """Baseline stats (from super) + classification overlay (accuracy/F1/...)."""
108
+ base = super().aggregate(criterion, per_row_results)
109
+ if base is None:
110
+ return None
111
+ return overlay_classification_metrics(base, per_row_results)
@@ -0,0 +1,223 @@
1
+ """UiPath eval criterion checker."""
2
+
3
+ import json
4
+ import logging
5
+ import re
6
+ import shlex
7
+ import uuid
8
+ from typing import TYPE_CHECKING, Any
9
+
10
+ from coder_eval.criteria.base import BaseCriterion, CheckContext, register_criterion
11
+ from coder_eval.models import CriterionResult, UiPathEvalCriterion
12
+
13
+
14
+ # Detector for "the `uipath` CLI is not available in the sandbox". Sandboxes run
15
+ # on Linux (/bin/sh — dash on Ubuntu), so exit code 127 + "command not found" is
16
+ # the only shell signal we need to recognize. The regex also matches the python
17
+ # ModuleNotFoundError format for tasks that run `python -m uipath`.
18
+ _UIPATH_MISSING_PATTERN = re.compile(
19
+ r"\buipath\b.*command not found|no module named ['\"]?uipath['\"]?(?:\s|$)",
20
+ re.IGNORECASE,
21
+ )
22
+
23
+
24
+ if TYPE_CHECKING:
25
+ from coder_eval.models.results import TurnRecord
26
+ from coder_eval.sandbox import Sandbox
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ @register_criterion
32
+ class UiPathEvalChecker(BaseCriterion[UiPathEvalCriterion]):
33
+ """Checker for UiPathEvalCriterion."""
34
+
35
+ criterion_type = "uipath_eval"
36
+
37
+ def _check_impl(
38
+ self,
39
+ criterion: UiPathEvalCriterion,
40
+ sandbox: "Sandbox",
41
+ reference_code: str | None = None,
42
+ *,
43
+ turn_records: list["TurnRecord"] | None = None,
44
+ context: CheckContext | None = None,
45
+ ) -> CriterionResult:
46
+ """Check UiPath agent evaluation results against specified thresholds.
47
+
48
+ Args:
49
+ criterion: UiPathEvalCriterion with agent_name, eval_set, and thresholds
50
+ sandbox: Sandbox instance for file access and command execution
51
+ reference_code: Not used for this criterion
52
+ turn_records: Not used for this criterion
53
+
54
+ Returns:
55
+ CriterionResult with score (0.0-1.0) based on threshold compliance
56
+ """
57
+ # Resolve eval_set path relative to task directory
58
+ eval_set_path = criterion.eval_set
59
+ task_dir = sandbox.task_dir
60
+ if task_dir:
61
+ resolved_path = (task_dir / criterion.eval_set).resolve()
62
+ if resolved_path.exists():
63
+ eval_set_path = str(resolved_path)
64
+
65
+ # Use a unique output filename to avoid conflicts
66
+ output_filename = f"uipath_eval_output_{uuid.uuid4().hex[:8]}.json"
67
+
68
+ # Run the evaluation command with properly escaped arguments
69
+ cmd = (
70
+ f"uv run uipath eval {shlex.quote(criterion.agent_name)} "
71
+ f"{shlex.quote(eval_set_path)} --no-report --output-file {shlex.quote(output_filename)}"
72
+ )
73
+ exit_code, _, stderr = sandbox.run_command(cmd)
74
+
75
+ if exit_code != 0:
76
+ stderr_text = stderr or ""
77
+ # Exit code 127 is the POSIX "command not found" signal; the regex
78
+ # also catches `ModuleNotFoundError` for tasks that invoke
79
+ # `python -m uipath` instead of the CLI.
80
+ cli_missing = exit_code == 127 or bool(_UIPATH_MISSING_PATTERN.search(stderr_text))
81
+ hint = ""
82
+ if cli_missing:
83
+ # The host's `[uipath]` extra installs `uipath` into the host
84
+ # venv; the sandbox runs in its own `uv` environment and must
85
+ # resolve `uipath` from the task's own deps.
86
+ hint = (
87
+ " — the sandbox could not resolve the `uipath` CLI. "
88
+ "Ensure the task's Python deps include `uipath` (the in-sandbox "
89
+ "dependency is separate from the host's `coder-eval[uipath]` extra)."
90
+ )
91
+ return CriterionResult(
92
+ criterion_type=criterion.type,
93
+ description=criterion.description,
94
+ score=0.0,
95
+ details=f"Command failed with exit code {exit_code}: {stderr}{hint}",
96
+ )
97
+
98
+ # Parse the output JSON file
99
+ try:
100
+ output_content = sandbox.get_file_content(output_filename)
101
+ results = json.loads(output_content)
102
+ except Exception as e:
103
+ return CriterionResult(
104
+ criterion_type=criterion.type,
105
+ description=criterion.description,
106
+ score=0.0,
107
+ details=f"Failed to parse {output_filename}: {e}",
108
+ )
109
+
110
+ if not isinstance(results, dict):
111
+ return CriterionResult(
112
+ criterion_type=criterion.type,
113
+ description=criterion.description,
114
+ score=0.0,
115
+ details=f"Expected JSON object in {output_filename}, got {type(results).__name__}",
116
+ )
117
+
118
+ # Calculate metrics from evaluation results
119
+ metrics = self._calculate_metrics(results)
120
+
121
+ # Compare metrics against thresholds
122
+ failed_metrics = []
123
+ for metric_name, threshold in criterion.thresholds.items():
124
+ if metric_name not in metrics:
125
+ failed_metrics.append(f"Missing metric: {metric_name}")
126
+ continue
127
+
128
+ metric_value = metrics[metric_name]
129
+ if not isinstance(metric_value, (int, float)):
130
+ failed_metrics.append(f"{metric_name}: Invalid type {type(metric_value).__name__} (expected number)")
131
+ elif metric_value < threshold:
132
+ failed_metrics.append(f"{metric_name}: {metric_value} < {threshold}")
133
+
134
+ # Calculate score based on how many thresholds are met
135
+ if not criterion.thresholds:
136
+ score = 1.0
137
+ else:
138
+ metrics_passed = len(criterion.thresholds) - len(failed_metrics)
139
+ score = metrics_passed / len(criterion.thresholds)
140
+
141
+ details = "All thresholds met." if not failed_metrics else f"Failed metrics: {', '.join(failed_metrics)}"
142
+
143
+ return CriterionResult(
144
+ criterion_type=criterion.type,
145
+ description=criterion.description,
146
+ score=score,
147
+ details=details,
148
+ )
149
+
150
+ def _calculate_metrics(self, results: Any) -> dict[str, float]:
151
+ """Calculate aggregated metrics from evaluation results.
152
+
153
+ Supports two formats:
154
+ 1. Complex format with evaluationSetResults (from UiPath agents)
155
+ 2. Simple format with flat metric keys (for testing/direct evaluation)
156
+
157
+ Args:
158
+ results: Parsed output.json with either evaluationSetResults or flat metrics
159
+
160
+ Returns:
161
+ Dictionary with calculated metrics
162
+ """
163
+ # Try complex format first (evaluationSetResults)
164
+ evaluation_set_results = results.get("evaluationSetResults", [])
165
+ if evaluation_set_results:
166
+ return self._calculate_metrics_from_evaluations(evaluation_set_results)
167
+
168
+ # Fall back to simple format (flat metrics)
169
+ # Keep all values including non-numeric ones, to catch type errors later
170
+ metrics = dict(results)
171
+
172
+ logger.debug(f"Calculated metrics: {metrics}")
173
+ return metrics
174
+
175
+ def _calculate_metrics_from_evaluations(self, evaluation_set_results: Any) -> dict[str, float]:
176
+ """Calculate metrics from evaluationSetResults structure.
177
+
178
+ Groups metrics by evaluator ID, averages scores per evaluator (treating
179
+ exceptions as 0.0), then computes macro average across all evaluators.
180
+
181
+ Args:
182
+ evaluation_set_results: List of evaluation results with evaluatorId
183
+
184
+ Returns:
185
+ Dictionary with calculated metrics, keyed by evaluator ID or "*" for macro average
186
+ """
187
+ evaluator_scores: dict[str, list[float]] = {} # evaluator_id -> list of scores
188
+
189
+ # Iterate through all evaluations and collect scores by evaluator
190
+ for evaluation in evaluation_set_results:
191
+ evaluation_run_results = evaluation.get("evaluationRunResults", [])
192
+ for run_result in evaluation_run_results:
193
+ evaluator_id = run_result.get("evaluatorId")
194
+ if not evaluator_id:
195
+ continue
196
+
197
+ result_data = run_result.get("result", {})
198
+ score = result_data.get("score", 0.0)
199
+
200
+ # Treat missing/error/non-numeric scores as 0.0
201
+ if not isinstance(score, (int, float)):
202
+ score = 0.0
203
+
204
+ if evaluator_id not in evaluator_scores:
205
+ evaluator_scores[evaluator_id] = []
206
+ evaluator_scores[evaluator_id].append(score)
207
+
208
+ # Calculate per-evaluator averages
209
+ metrics = {}
210
+ evaluator_averages = []
211
+ for evaluator_id, scores in evaluator_scores.items():
212
+ avg_score = sum(scores) / len(scores) if scores else 0.0
213
+ metrics[evaluator_id] = avg_score
214
+ evaluator_averages.append(avg_score)
215
+
216
+ # Calculate macro average (average of evaluator averages)
217
+ if evaluator_averages:
218
+ metrics["*"] = sum(evaluator_averages) / len(evaluator_averages)
219
+ else:
220
+ metrics["*"] = 0.0
221
+
222
+ logger.debug(f"Calculated metrics: {metrics}")
223
+ return metrics
@@ -0,0 +1,26 @@
1
+ """Error categorization, retry logic, and error context capture.
2
+
3
+ This package provides production-grade error handling with:
4
+ - A categorized error taxonomy with per-category retry eligibility
5
+ - Exponential backoff retry logic with jitter
6
+ - Rich error context capture for debugging
7
+ - Actionable error tips for users
8
+ """
9
+
10
+ from .agent import AgentConfigError, AgentCrashError, format_timeout_reason, truncate_crash_message
11
+ from .budget import BudgetExceededError
12
+ from .judge import JudgeInfrastructureError
13
+ from .timeout import EvaluationTimeoutError, TaskTimeoutError, TurnTimeoutError
14
+
15
+
16
+ __all__ = [
17
+ "AgentConfigError",
18
+ "AgentCrashError",
19
+ "BudgetExceededError",
20
+ "EvaluationTimeoutError",
21
+ "JudgeInfrastructureError",
22
+ "TaskTimeoutError",
23
+ "TurnTimeoutError",
24
+ "format_timeout_reason",
25
+ "truncate_crash_message",
26
+ ]
@@ -0,0 +1,26 @@
1
+ """Agent-specific exceptions and crash-reason formatting helpers."""
2
+
3
+ CRASH_REASON_MAX_CHARS = 200
4
+
5
+
6
+ def format_timeout_reason(timeout_seconds: float) -> str:
7
+ """Canonical ``crash_reason`` string for an agent-turn timeout (integer seconds)."""
8
+ return f"Agent turn timed out after {timeout_seconds:.0f}s"
9
+
10
+
11
+ def truncate_crash_message(message: str, *, limit: int = CRASH_REASON_MAX_CHARS) -> str:
12
+ """Cap a crash-reason string with a single Unicode ellipsis on overflow."""
13
+ return message if len(message) <= limit else message[:limit] + "…"
14
+
15
+
16
+ class AgentCrashError(RuntimeError):
17
+ """Mid-turn agent failure; routed to AGENT_CRASH by isinstance."""
18
+
19
+
20
+ class AgentConfigError(RuntimeError):
21
+ """Agent prerequisite missing (env var, SDK path, build artifact). Non-retryable.
22
+
23
+ Routed to ``AGENT_CONFIG_ERROR`` by isinstance — preferred over substring
24
+ matching on a message, which can silently re-categorise a reworded
25
+ RuntimeError as the retryable ``AGENT_API_ERROR``.
26
+ """
@@ -0,0 +1,28 @@
1
+ """Budget-exceeded exceptions for evaluation lifecycle."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class BudgetExceededError(Exception):
7
+ """Raised when a RunLimits budget is exceeded between agent turns.
8
+
9
+ Carries which budget tripped and the over-budget value so the
10
+ orchestrator can record the status reason without re-computing.
11
+ """
12
+
13
+ def __init__(
14
+ self,
15
+ budget_name: str,
16
+ *,
17
+ actual: float,
18
+ limit: float,
19
+ task_id: str | None = None,
20
+ iteration: int | None = None,
21
+ ):
22
+ self.budget_name = budget_name
23
+ self.actual = actual
24
+ self.limit = limit
25
+ self.task_id = task_id
26
+ self.iteration = iteration
27
+ suffix = f" (iteration {iteration})" if iteration is not None else ""
28
+ super().__init__(f"{budget_name} budget exceeded: {actual:g} > {limit:g}{suffix}")
@@ -0,0 +1,205 @@
1
+ """Error categories and configuration for retry logic.
2
+
3
+ This module defines error categories, retry configuration, and structured
4
+ error context for production-grade error handling.
5
+ """
6
+
7
+ from enum import Enum
8
+
9
+ from pydantic import BaseModel, Field
10
+
11
+
12
+ class ErrorCategory(Enum):
13
+ """Categorize errors for retry logic and reporting.
14
+
15
+ Categories are grouped by component:
16
+ - Agent errors (AGENT_*)
17
+ - Sandbox errors (SANDBOX_*, VENV_*, PACKAGE_*, etc.)
18
+ - Evaluator errors (CRITERION_*, LLM_REVIEWER_*)
19
+ - Task errors (TASK_*)
20
+ - System errors (DISK_FULL, OUT_OF_MEMORY)
21
+
22
+ Each category has an associated retry policy in RETRY_CONFIG.
23
+
24
+ NB: each ``value`` is persisted verbatim into task.json's
25
+ ``error_details.error_category`` — a cross-repo contract surface read by the
26
+ external eval-runner/dashboard. Adding/renaming/removing a member is a
27
+ contract change: update the pinned set in
28
+ ``tests/test_error_handling.py::test_error_category_values_are_a_pinned_contract``
29
+ and flag in the PR that a new persisted value now appears downstream (which
30
+ needs a display label/icon). FinalStatus bucketing is unchanged, so the
31
+ blast radius is display/filter only.
32
+
33
+ Removed 2026-06-22: ``TESTS_FAILED`` and ``SANDBOX_COMMAND_ERROR`` — neither
34
+ had a producing ``categorize_error`` path or a downstream consumer. The
35
+ liveness test in ``tests/test_error_handling.py`` now keeps every member
36
+ producible, so a future dead member fails CI.
37
+ """
38
+
39
+ # Agent Errors - Communication and execution errors
40
+ AGENT_TIMEOUT = "agent_timeout" # Agent exceeded time limit (NOT retryable)
41
+ AGENT_API_ERROR = "agent_api_error" # API connection/network error (retryable)
42
+ AGENT_RATE_LIMIT = "agent_rate_limit" # API rate limit (retryable with long delay)
43
+ AGENT_AUTH_ERROR = "agent_auth_error" # Invalid API key (NOT retryable)
44
+ AGENT_BILLING_ERROR = "agent_billing_error" # Insufficient credits/billing issue (NOT retryable)
45
+ AGENT_CRASH = "agent_crash" # Agent process crashed (retryable — CLI crashes are often transient)
46
+ AGENT_INVALID_OUTPUT = "agent_invalid_output" # Malformed response (NOT retryable)
47
+ AGENT_CONFIG_ERROR = "agent_config_error" # Missing prerequisite/misconfiguration (NOT retryable)
48
+
49
+ # Sandbox Errors - Environment setup and execution
50
+ SANDBOX_SETUP_ERROR = "sandbox_setup_error" # Failed to create sandbox (retryable)
51
+ VENV_CREATION_ERROR = "venv_creation_error" # Virtual env creation failed (retryable)
52
+ PACKAGE_INSTALL_ERROR = "package_install_error" # pip install failed (retryable)
53
+ TEMPLATE_COPY_ERROR = "template_copy_error" # Template copy failed (retryable)
54
+ GIT_CLONE_ERROR = "git_clone_error" # Git clone failed (retryable)
55
+
56
+ # Criterion/Evaluator Errors
57
+ CRITERION_CHECK_ERROR = "criterion_check_error" # Error checking criterion (NOT retryable)
58
+
59
+ # Task Errors - Task definition and loading
60
+ TASK_NOT_FOUND = "task_not_found" # Task file doesn't exist (NOT retryable)
61
+ TASK_INVALID = "task_invalid" # Malformed task.yaml (NOT retryable)
62
+
63
+ # System Errors - Resource exhaustion
64
+ DISK_FULL = "disk_full" # No disk space (NOT retryable)
65
+ OUT_OF_MEMORY = "out_of_memory" # OOM error (NOT retryable)
66
+
67
+ # Budget governance — RunLimits caps tripped (NOT retryable — retrying compounds the breach)
68
+ BUDGET_EXCEEDED = "budget_exceeded"
69
+
70
+ # Unknown
71
+ UNKNOWN = "unknown" # Uncategorized error (NOT retryable)
72
+
73
+
74
+ class RetryConfig(BaseModel):
75
+ """Configuration for retry behavior.
76
+
77
+ Defines how many times to retry and with what backoff strategy.
78
+ """
79
+
80
+ max_retries: int = Field(default=0, ge=0, description="Maximum retry attempts (0 = no retry)")
81
+ backoff_multiplier: float = Field(default=2.0, gt=0, description="Exponential backoff multiplier")
82
+ initial_delay: float = Field(default=5.0, gt=0, description="Initial delay in seconds")
83
+
84
+
85
+ class ErrorContext(BaseModel):
86
+ """Structured error context for reporting and debugging.
87
+
88
+ This model provides type-safe error context with validation.
89
+ Use error_context_to_dict() to convert to dict for EvaluationResult.error_details.
90
+ """
91
+
92
+ error_category: str = Field(description="Error category value")
93
+ error_message: str = Field(description="Exception message")
94
+ stack_trace: str = Field(description="Full stack trace")
95
+ task_id: str = Field(description="Task identifier")
96
+ component: str | None = Field(default=None, description="Component that failed")
97
+ agent_name: str | None = Field(default=None, description="Agent name if applicable")
98
+ attempt_number: int = Field(ge=1, description="Attempt number (1-indexed)")
99
+ is_retryable: bool = Field(description="Whether error is retryable")
100
+ retry_delay_seconds: float = Field(ge=0.0, description="Delay before next retry")
101
+ disk_usage_gb: float | None = Field(default=None, description="Disk usage in GB")
102
+ memory_usage_gb: float | None = Field(default=None, description="Memory usage in GB")
103
+ agent_stdout: str | None = Field(default=None, description="Agent stdout (truncated)")
104
+ agent_stderr: str | None = Field(default=None, description="Agent stderr (truncated)")
105
+ timestamp: str = Field(description="ISO timestamp when error occurred")
106
+
107
+
108
+ # Retry configuration per error category
109
+ # Non-retryable errors have max_retries=0 (default)
110
+ RETRY_CONFIG: dict[ErrorCategory, RetryConfig] = {
111
+ # Agent errors - API issues are retryable
112
+ ErrorCategory.AGENT_API_ERROR: RetryConfig(
113
+ max_retries=3,
114
+ backoff_multiplier=2.0,
115
+ initial_delay=5.0,
116
+ ),
117
+ ErrorCategory.AGENT_RATE_LIMIT: RetryConfig(
118
+ max_retries=5,
119
+ backoff_multiplier=3.0,
120
+ initial_delay=60.0, # 1 minute initial delay
121
+ ),
122
+ ErrorCategory.AGENT_CRASH: RetryConfig(
123
+ max_retries=2,
124
+ backoff_multiplier=2.0,
125
+ initial_delay=5.0,
126
+ ),
127
+ # Sandbox errors - transient failures are retryable
128
+ ErrorCategory.SANDBOX_SETUP_ERROR: RetryConfig(
129
+ max_retries=2,
130
+ backoff_multiplier=1.5,
131
+ initial_delay=10.0,
132
+ ),
133
+ ErrorCategory.PACKAGE_INSTALL_ERROR: RetryConfig(
134
+ max_retries=2,
135
+ backoff_multiplier=2.0,
136
+ initial_delay=5.0,
137
+ ),
138
+ ErrorCategory.VENV_CREATION_ERROR: RetryConfig(
139
+ max_retries=2,
140
+ backoff_multiplier=2.0,
141
+ initial_delay=5.0,
142
+ ),
143
+ ErrorCategory.TEMPLATE_COPY_ERROR: RetryConfig(
144
+ max_retries=2,
145
+ backoff_multiplier=1.5,
146
+ initial_delay=5.0,
147
+ ),
148
+ ErrorCategory.GIT_CLONE_ERROR: RetryConfig(
149
+ max_retries=3,
150
+ backoff_multiplier=2.0,
151
+ initial_delay=10.0,
152
+ ),
153
+ # All other errors are NOT retryable (max_retries=0 by default)
154
+ }
155
+
156
+
157
+ # Error tip system for better UX
158
+ ERROR_TIPS = {
159
+ ErrorCategory.AGENT_TIMEOUT: (
160
+ "Agent or task timed out. Consider increasing the limit "
161
+ "(-D run_limits.turn_timeout=… or -D run_limits.task_timeout=…), "
162
+ "or simplifying the task to require fewer iterations."
163
+ ),
164
+ ErrorCategory.AGENT_RATE_LIMIT: (
165
+ "Rate limit hit. Consider using --max-parallel=1 to reduce concurrent API calls, "
166
+ "or check your API key quota at your provider's dashboard."
167
+ ),
168
+ ErrorCategory.AGENT_AUTH_ERROR: (
169
+ "Authentication failed. Verify your ANTHROPIC_API_KEY is set correctly in .env file and has not expired."
170
+ ),
171
+ ErrorCategory.PACKAGE_INSTALL_ERROR: (
172
+ "Package installation failed. Check your network connection, or try clearing pip cache with 'pip cache purge'."
173
+ ),
174
+ ErrorCategory.DISK_FULL: (
175
+ "Disk full. Run 'coder-eval health' to check usage, then 'coder-eval gc' to free space, "
176
+ "or delete old runs manually from the runs/ directory."
177
+ ),
178
+ ErrorCategory.VENV_CREATION_ERROR: (
179
+ "Virtual environment creation failed. Ensure Python 3.13+ is installed and accessible."
180
+ ),
181
+ ErrorCategory.GIT_CLONE_ERROR: (
182
+ "Git clone failed. Check your network connection and verify the repository URL is accessible."
183
+ ),
184
+ ErrorCategory.AGENT_API_ERROR: (
185
+ "API connection error. Check your network connection and verify the API endpoint is accessible."
186
+ ),
187
+ ErrorCategory.AGENT_BILLING_ERROR: (
188
+ "Insufficient API credits or billing issue. Check your account balance and billing settings "
189
+ "at your provider's dashboard."
190
+ ),
191
+ ErrorCategory.AGENT_CRASH: (
192
+ "Claude CLI process exited unexpectedly. Common causes: insufficient API credits, "
193
+ "invalid API key, or network issues. Run 'claude --version' to verify the CLI works, "
194
+ "then try running your prompt directly with 'claude' to see the actual error."
195
+ ),
196
+ ErrorCategory.BUDGET_EXCEEDED: (
197
+ "RunLimits budget exceeded. Raise the cap in the task's run_limits block, or simplify "
198
+ "the prompt to use fewer tokens."
199
+ ),
200
+ ErrorCategory.AGENT_CONFIG_ERROR: (
201
+ "Agent configuration error — a required environment variable, SDK path, or "
202
+ "build artifact is missing. Check the error message for the specific prerequisite, "
203
+ "and see the agent plugin's setup docs."
204
+ ),
205
+ }