codefix-env 0.2.1__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.
- codefix_env/__init__.py +80 -0
- codefix_env/cli.py +83 -0
- codefix_env/client.py +194 -0
- codefix_env/env.py +503 -0
- codefix_env/models.py +234 -0
- codefix_env/rewards.py +157 -0
- codefix_env/tasks/__init__.py +75 -0
- codefix_env/tasks/easy.py +244 -0
- codefix_env/tasks/hard.py +430 -0
- codefix_env/tasks/medium.py +342 -0
- codefix_env/utils/__init__.py +33 -0
- codefix_env/utils/metrics.py +165 -0
- codefix_env/utils/reward_model.py +79 -0
- codefix_env/utils/sandbox.py +392 -0
- codefix_env-0.2.1.dist-info/METADATA +663 -0
- codefix_env-0.2.1.dist-info/RECORD +20 -0
- codefix_env-0.2.1.dist-info/WHEEL +5 -0
- codefix_env-0.2.1.dist-info/entry_points.txt +2 -0
- codefix_env-0.2.1.dist-info/licenses/LICENSE +201 -0
- codefix_env-0.2.1.dist-info/top_level.txt +1 -0
codefix_env/env.py
ADDED
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CodeFixEnvironment — Core Server-Side Environment
|
|
3
|
+
===================================================
|
|
4
|
+
Implements the OpenEnv-compatible interface: reset(), step(), state().
|
|
5
|
+
|
|
6
|
+
Features:
|
|
7
|
+
- Full action validation
|
|
8
|
+
- Sandbox code execution per step
|
|
9
|
+
- Dense shaped rewards
|
|
10
|
+
- Diff tracking
|
|
11
|
+
- Hint system with penalty
|
|
12
|
+
- Episode state management
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import difflib
|
|
18
|
+
import uuid
|
|
19
|
+
from copy import deepcopy
|
|
20
|
+
|
|
21
|
+
import structlog
|
|
22
|
+
|
|
23
|
+
from codefix_env.models import (
|
|
24
|
+
ActionType,
|
|
25
|
+
CodeFixAction,
|
|
26
|
+
CodeFixObservation,
|
|
27
|
+
CodeFixState,
|
|
28
|
+
Difficulty,
|
|
29
|
+
StepResult,
|
|
30
|
+
Task,
|
|
31
|
+
TerminationReason,
|
|
32
|
+
TestResult,
|
|
33
|
+
)
|
|
34
|
+
from codefix_env.rewards import RewardPipeline, default_pipeline
|
|
35
|
+
from codefix_env.tasks import load_task, random_task
|
|
36
|
+
from codefix_env.utils.metrics import compute_final_score, compute_test_score
|
|
37
|
+
from codefix_env.utils.sandbox import ExecutionResult, run_all_tests
|
|
38
|
+
|
|
39
|
+
logger = structlog.get_logger(__name__)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class CodeFixEnvironment:
|
|
43
|
+
"""
|
|
44
|
+
Server-side RL environment for automated code debugging.
|
|
45
|
+
|
|
46
|
+
Gymnasium-compatible interface:
|
|
47
|
+
obs = env.reset(task_id="easy-001-missing-return")
|
|
48
|
+
result: StepResult = env.step(action)
|
|
49
|
+
state: CodeFixState = env.state()
|
|
50
|
+
|
|
51
|
+
The environment is stateful (one episode at a time per instance).
|
|
52
|
+
For parallel training, spawn one instance per worker.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
reward_pipeline: RewardPipeline = default_pipeline,
|
|
58
|
+
default_difficulty: Difficulty = Difficulty.MEDIUM,
|
|
59
|
+
max_steps: int = 20,
|
|
60
|
+
):
|
|
61
|
+
self.reward_pipeline = reward_pipeline
|
|
62
|
+
self.default_difficulty = default_difficulty
|
|
63
|
+
self.default_max_steps = max_steps
|
|
64
|
+
|
|
65
|
+
# Episode state (initialised on reset)
|
|
66
|
+
self._task: Task | None = None
|
|
67
|
+
self._state: CodeFixState | None = None
|
|
68
|
+
self._obs: CodeFixObservation | None = None
|
|
69
|
+
self._current_code: str = ""
|
|
70
|
+
self._prev_obs: CodeFixObservation | None = None
|
|
71
|
+
|
|
72
|
+
# ── Public API ────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
def reset(
|
|
75
|
+
self,
|
|
76
|
+
task_id: str | None = None,
|
|
77
|
+
difficulty: Difficulty | None = None,
|
|
78
|
+
seed: int | None = None,
|
|
79
|
+
) -> CodeFixObservation:
|
|
80
|
+
"""
|
|
81
|
+
Start a new episode.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
task_id: Specific task to load. If None, random task is chosen.
|
|
85
|
+
difficulty: Filter random task by difficulty.
|
|
86
|
+
seed: Random seed (for reproducibility).
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
Initial CodeFixObservation.
|
|
90
|
+
"""
|
|
91
|
+
if seed is not None:
|
|
92
|
+
import random
|
|
93
|
+
|
|
94
|
+
random.seed(seed)
|
|
95
|
+
|
|
96
|
+
# Load task
|
|
97
|
+
if task_id:
|
|
98
|
+
self._task = load_task(task_id)
|
|
99
|
+
else:
|
|
100
|
+
self._task = random_task(difficulty=difficulty or self.default_difficulty)
|
|
101
|
+
|
|
102
|
+
# Initialise episode
|
|
103
|
+
# constructor max_steps is a hard cap: min(constructor_cap, task_default)
|
|
104
|
+
# e.g. CodeFixEnvironment(max_steps=3) will always truncate at 3 steps
|
|
105
|
+
task_max = self._task.max_steps or self.default_max_steps
|
|
106
|
+
effective_max = min(self.default_max_steps, task_max)
|
|
107
|
+
self._current_code = self._task.buggy_code
|
|
108
|
+
self._state = CodeFixState(
|
|
109
|
+
episode_id=str(uuid.uuid4()),
|
|
110
|
+
task_id=self._task.id,
|
|
111
|
+
max_steps=effective_max,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# Build initial observation (no tests run yet)
|
|
115
|
+
self._obs = self._build_observation(
|
|
116
|
+
tests_run=False,
|
|
117
|
+
test_results=[],
|
|
118
|
+
shaped_reward=0.0,
|
|
119
|
+
feedback=f"Episode started. Task: '{self._task.title}'\n{self._task.description}",
|
|
120
|
+
)
|
|
121
|
+
self._prev_obs = deepcopy(self._obs)
|
|
122
|
+
|
|
123
|
+
logger.info(
|
|
124
|
+
"episode_reset",
|
|
125
|
+
episode_id=self._state.episode_id,
|
|
126
|
+
task_id=self._task.id,
|
|
127
|
+
difficulty=self._task.difficulty,
|
|
128
|
+
)
|
|
129
|
+
return self._obs
|
|
130
|
+
|
|
131
|
+
def step(self, action: CodeFixAction) -> StepResult:
|
|
132
|
+
"""
|
|
133
|
+
Execute one action in the environment.
|
|
134
|
+
|
|
135
|
+
Returns StepResult with (observation, reward, done, info).
|
|
136
|
+
"""
|
|
137
|
+
if self._state is None or self._task is None:
|
|
138
|
+
raise RuntimeError("Call reset() before step().")
|
|
139
|
+
# Allow GET_HINT even on a done episode (graceful feedback, no crash)
|
|
140
|
+
# use_enum_values=True means action_type is stored as plain string not enum member
|
|
141
|
+
action_type_str = (
|
|
142
|
+
action.action_type if isinstance(action.action_type, str) else action.action_type.value
|
|
143
|
+
)
|
|
144
|
+
if self._state.done and action_type_str != ActionType.GET_HINT.value:
|
|
145
|
+
raise RuntimeError("Episode is done. Call reset() to start a new episode.")
|
|
146
|
+
|
|
147
|
+
self._prev_obs = deepcopy(self._obs)
|
|
148
|
+
self._state.step_count += 1
|
|
149
|
+
|
|
150
|
+
# Dispatch action
|
|
151
|
+
action_type = ActionType(action.action_type)
|
|
152
|
+
try:
|
|
153
|
+
obs, immediate_reward = self._dispatch(action_type, action)
|
|
154
|
+
except Exception as e: # noqa: BLE001
|
|
155
|
+
obs = self._build_observation(
|
|
156
|
+
tests_run=False,
|
|
157
|
+
test_results=[],
|
|
158
|
+
shaped_reward=0.0,
|
|
159
|
+
feedback=f"⚠️ Error executing action: {e}",
|
|
160
|
+
error_message=str(e),
|
|
161
|
+
)
|
|
162
|
+
immediate_reward = -0.05
|
|
163
|
+
|
|
164
|
+
# Record in state
|
|
165
|
+
self._state.action_history.append(
|
|
166
|
+
{
|
|
167
|
+
"step": self._state.step_count,
|
|
168
|
+
"action_type": action_type.value,
|
|
169
|
+
"line_number": action.line_number,
|
|
170
|
+
"reward": immediate_reward,
|
|
171
|
+
}
|
|
172
|
+
)
|
|
173
|
+
self._state.total_reward += immediate_reward
|
|
174
|
+
|
|
175
|
+
# Update obs ref
|
|
176
|
+
self._obs = obs
|
|
177
|
+
|
|
178
|
+
# Check terminal conditions
|
|
179
|
+
done, truncated, reason = self._check_terminal(obs)
|
|
180
|
+
if done or truncated:
|
|
181
|
+
self._state.done = True
|
|
182
|
+
self._state.solved = obs.all_tests_pass
|
|
183
|
+
final_score = compute_final_score(
|
|
184
|
+
obs.tests_passed,
|
|
185
|
+
obs.tests_total,
|
|
186
|
+
self._state.step_count,
|
|
187
|
+
self._state.hints_used,
|
|
188
|
+
self.reward_pipeline.cfg,
|
|
189
|
+
)
|
|
190
|
+
obs.score_so_far = final_score
|
|
191
|
+
obs.done = True
|
|
192
|
+
obs.termination_reason = reason
|
|
193
|
+
metrics = self.reward_pipeline.build_metrics(self._task, obs, self._state.total_reward)
|
|
194
|
+
logger.info("episode_done", **metrics.to_dict())
|
|
195
|
+
|
|
196
|
+
result = StepResult(
|
|
197
|
+
observation=obs,
|
|
198
|
+
reward=immediate_reward,
|
|
199
|
+
done=done or truncated,
|
|
200
|
+
truncated=truncated,
|
|
201
|
+
info={
|
|
202
|
+
"episode_id": self._state.episode_id,
|
|
203
|
+
"task_id": self._task.id,
|
|
204
|
+
"total_reward": self._state.total_reward,
|
|
205
|
+
},
|
|
206
|
+
)
|
|
207
|
+
return result
|
|
208
|
+
|
|
209
|
+
def state(self) -> CodeFixState:
|
|
210
|
+
"""Return internal episode state (non-destructive)."""
|
|
211
|
+
if self._state is None:
|
|
212
|
+
raise RuntimeError("Call reset() first.")
|
|
213
|
+
return deepcopy(self._state)
|
|
214
|
+
|
|
215
|
+
# ── Action Handlers ───────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
def _dispatch(
|
|
218
|
+
self,
|
|
219
|
+
action_type: ActionType,
|
|
220
|
+
action: CodeFixAction,
|
|
221
|
+
) -> tuple[CodeFixObservation, float]:
|
|
222
|
+
"""Route action to its handler. Returns (obs, reward)."""
|
|
223
|
+
|
|
224
|
+
if action_type == ActionType.RUN_TESTS:
|
|
225
|
+
return self._action_run_tests()
|
|
226
|
+
|
|
227
|
+
elif action_type == ActionType.EDIT_LINE:
|
|
228
|
+
return self._action_edit_line(action.line_number, action.new_content or "")
|
|
229
|
+
|
|
230
|
+
elif action_type == ActionType.INSERT_LINE:
|
|
231
|
+
return self._action_insert_line(action.line_number, action.new_content or "")
|
|
232
|
+
|
|
233
|
+
elif action_type == ActionType.DELETE_LINE:
|
|
234
|
+
return self._action_delete_line(action.line_number)
|
|
235
|
+
|
|
236
|
+
elif action_type == ActionType.GET_HINT:
|
|
237
|
+
return self._action_get_hint()
|
|
238
|
+
|
|
239
|
+
elif action_type == ActionType.SUBMIT_FIX:
|
|
240
|
+
return self._action_submit()
|
|
241
|
+
|
|
242
|
+
elif action_type == ActionType.VIEW_CODE:
|
|
243
|
+
return self._action_view_code()
|
|
244
|
+
|
|
245
|
+
else:
|
|
246
|
+
return (
|
|
247
|
+
self._build_observation(
|
|
248
|
+
tests_run=False,
|
|
249
|
+
test_results=[],
|
|
250
|
+
shaped_reward=0.0,
|
|
251
|
+
feedback=f"Unknown action: {action_type}",
|
|
252
|
+
error_message="UnknownAction",
|
|
253
|
+
),
|
|
254
|
+
-0.02,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
def _action_run_tests(self) -> tuple[CodeFixObservation, float]:
|
|
258
|
+
results = run_all_tests(self._current_code, self._task.test_cases)
|
|
259
|
+
test_results = self._parse_results(results)
|
|
260
|
+
passed = sum(1 for r in test_results if r.passed)
|
|
261
|
+
obs = self._build_observation(
|
|
262
|
+
tests_run=True,
|
|
263
|
+
test_results=test_results,
|
|
264
|
+
shaped_reward=0.0,
|
|
265
|
+
feedback=f"Tests run: {passed}/{len(test_results)} passing.",
|
|
266
|
+
)
|
|
267
|
+
reward = self.reward_pipeline.step_reward(
|
|
268
|
+
self._prev_obs, obs, ActionType.RUN_TESTS, self._task
|
|
269
|
+
)
|
|
270
|
+
obs.shaped_reward = reward
|
|
271
|
+
return obs, reward
|
|
272
|
+
|
|
273
|
+
def _action_edit_line(
|
|
274
|
+
self, line_num: int, new_content: str
|
|
275
|
+
) -> tuple[CodeFixObservation, float]:
|
|
276
|
+
lines = self._current_code.splitlines(keepends=True)
|
|
277
|
+
if line_num < 1 or line_num > len(lines):
|
|
278
|
+
obs = self._build_observation(
|
|
279
|
+
tests_run=False,
|
|
280
|
+
test_results=[],
|
|
281
|
+
shaped_reward=-0.02,
|
|
282
|
+
feedback=f"Invalid line number {line_num}. Code has {len(lines)} lines.",
|
|
283
|
+
error_message="InvalidLineNumber",
|
|
284
|
+
)
|
|
285
|
+
return obs, -0.02
|
|
286
|
+
|
|
287
|
+
# Preserve original line ending
|
|
288
|
+
ending = "\n" if not new_content.endswith("\n") else ""
|
|
289
|
+
lines[line_num - 1] = new_content + ending
|
|
290
|
+
self._current_code = "".join(lines)
|
|
291
|
+
|
|
292
|
+
obs = self._build_observation(
|
|
293
|
+
tests_run=False,
|
|
294
|
+
test_results=[],
|
|
295
|
+
shaped_reward=0.0,
|
|
296
|
+
feedback=f"Line {line_num} edited.",
|
|
297
|
+
)
|
|
298
|
+
reward = self.reward_pipeline.step_reward(
|
|
299
|
+
self._prev_obs, obs, ActionType.EDIT_LINE, self._task
|
|
300
|
+
)
|
|
301
|
+
obs.shaped_reward = reward
|
|
302
|
+
return obs, reward
|
|
303
|
+
|
|
304
|
+
def _action_insert_line(
|
|
305
|
+
self, after_line: int, content: str
|
|
306
|
+
) -> tuple[CodeFixObservation, float]:
|
|
307
|
+
lines = self._current_code.splitlines(keepends=True)
|
|
308
|
+
idx = min(after_line, len(lines))
|
|
309
|
+
if not content.endswith("\n"):
|
|
310
|
+
content += "\n"
|
|
311
|
+
lines.insert(idx, content)
|
|
312
|
+
self._current_code = "".join(lines)
|
|
313
|
+
obs = self._build_observation(
|
|
314
|
+
tests_run=False,
|
|
315
|
+
test_results=[],
|
|
316
|
+
shaped_reward=0.0,
|
|
317
|
+
feedback=f"Line inserted after line {after_line}.",
|
|
318
|
+
)
|
|
319
|
+
reward = self.reward_pipeline.step_reward(
|
|
320
|
+
self._prev_obs, obs, ActionType.INSERT_LINE, self._task
|
|
321
|
+
)
|
|
322
|
+
obs.shaped_reward = reward
|
|
323
|
+
return obs, reward
|
|
324
|
+
|
|
325
|
+
def _action_delete_line(self, line_num: int) -> tuple[CodeFixObservation, float]:
|
|
326
|
+
lines = self._current_code.splitlines(keepends=True)
|
|
327
|
+
if line_num < 1 or line_num > len(lines):
|
|
328
|
+
obs = self._build_observation(
|
|
329
|
+
tests_run=False,
|
|
330
|
+
test_results=[],
|
|
331
|
+
shaped_reward=-0.02,
|
|
332
|
+
feedback=f"Invalid line number {line_num}.",
|
|
333
|
+
error_message="InvalidLineNumber",
|
|
334
|
+
)
|
|
335
|
+
return obs, -0.02
|
|
336
|
+
del lines[line_num - 1]
|
|
337
|
+
self._current_code = "".join(lines)
|
|
338
|
+
obs = self._build_observation(
|
|
339
|
+
tests_run=False,
|
|
340
|
+
test_results=[],
|
|
341
|
+
shaped_reward=0.0,
|
|
342
|
+
feedback=f"Line {line_num} deleted.",
|
|
343
|
+
)
|
|
344
|
+
reward = self.reward_pipeline.step_reward(
|
|
345
|
+
self._prev_obs, obs, ActionType.DELETE_LINE, self._task
|
|
346
|
+
)
|
|
347
|
+
obs.shaped_reward = reward
|
|
348
|
+
return obs, reward
|
|
349
|
+
|
|
350
|
+
def _action_get_hint(self) -> tuple[CodeFixObservation, float]:
|
|
351
|
+
hints_used = self._state.hints_used
|
|
352
|
+
hints = self._task.hints
|
|
353
|
+
if not hints:
|
|
354
|
+
feedback = "No hints available for this task."
|
|
355
|
+
elif hints_used < len(hints):
|
|
356
|
+
feedback = f"💡 Hint {hints_used + 1}/{len(hints)}: {hints[hints_used]}"
|
|
357
|
+
self._state.hints_used += 1
|
|
358
|
+
else:
|
|
359
|
+
feedback = "No more hints available."
|
|
360
|
+
|
|
361
|
+
penalty = -self._task.hint_penalty if hints_used < len(hints) else 0.0
|
|
362
|
+
obs = self._build_observation(
|
|
363
|
+
tests_run=False,
|
|
364
|
+
test_results=[],
|
|
365
|
+
shaped_reward=penalty,
|
|
366
|
+
feedback=feedback,
|
|
367
|
+
)
|
|
368
|
+
return obs, penalty
|
|
369
|
+
|
|
370
|
+
def _action_submit(self) -> tuple[CodeFixObservation, float]:
|
|
371
|
+
"""Run all tests and compute final reward."""
|
|
372
|
+
results = run_all_tests(self._current_code, self._task.test_cases)
|
|
373
|
+
test_results = self._parse_results(results)
|
|
374
|
+
passed = sum(1 for r in test_results if r.passed)
|
|
375
|
+
|
|
376
|
+
obs = self._build_observation(
|
|
377
|
+
tests_run=True,
|
|
378
|
+
test_results=test_results,
|
|
379
|
+
shaped_reward=0.0,
|
|
380
|
+
feedback=(
|
|
381
|
+
f"✅ Submitted! {passed}/{len(test_results)} tests passing."
|
|
382
|
+
if passed == len(test_results)
|
|
383
|
+
else f"❌ Submitted with {passed}/{len(test_results)} tests passing."
|
|
384
|
+
),
|
|
385
|
+
)
|
|
386
|
+
obs.done = True
|
|
387
|
+
obs.termination_reason = TerminationReason.SUBMITTED
|
|
388
|
+
|
|
389
|
+
reward = self.reward_pipeline.episode_reward(obs, self._task)
|
|
390
|
+
obs.shaped_reward = reward
|
|
391
|
+
obs.score_so_far = reward
|
|
392
|
+
self._state.done = True
|
|
393
|
+
return obs, reward
|
|
394
|
+
|
|
395
|
+
def _action_view_code(self) -> tuple[CodeFixObservation, float]:
|
|
396
|
+
obs = self._build_observation(
|
|
397
|
+
tests_run=False,
|
|
398
|
+
test_results=[],
|
|
399
|
+
shaped_reward=0.0,
|
|
400
|
+
feedback="Code viewed.",
|
|
401
|
+
)
|
|
402
|
+
return obs, 0.0
|
|
403
|
+
|
|
404
|
+
# ── Helpers ───────────────────────────────────────────────────────────────
|
|
405
|
+
|
|
406
|
+
def _build_observation(
|
|
407
|
+
self,
|
|
408
|
+
tests_run: bool,
|
|
409
|
+
test_results: list[TestResult],
|
|
410
|
+
shaped_reward: float,
|
|
411
|
+
feedback: str = "",
|
|
412
|
+
error_message: str = "",
|
|
413
|
+
) -> CodeFixObservation:
|
|
414
|
+
"""Construct a full CodeFixObservation from current state."""
|
|
415
|
+
assert self._task and self._state
|
|
416
|
+
|
|
417
|
+
passed = (
|
|
418
|
+
sum(1 for r in test_results if r.passed)
|
|
419
|
+
if tests_run
|
|
420
|
+
else (self._obs.tests_passed if self._obs else 0)
|
|
421
|
+
)
|
|
422
|
+
total = len(self._task.test_cases)
|
|
423
|
+
all_pass = passed == total and tests_run
|
|
424
|
+
score = (
|
|
425
|
+
compute_test_score(passed, total)
|
|
426
|
+
if tests_run
|
|
427
|
+
else (self._obs.score_so_far if self._obs else 0.0)
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
# Build unified diff
|
|
431
|
+
diff = "".join(
|
|
432
|
+
difflib.unified_diff(
|
|
433
|
+
self._task.buggy_code.splitlines(keepends=True),
|
|
434
|
+
self._current_code.splitlines(keepends=True),
|
|
435
|
+
fromfile="original.py",
|
|
436
|
+
tofile="current.py",
|
|
437
|
+
)
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
return CodeFixObservation(
|
|
441
|
+
current_code=self._current_code,
|
|
442
|
+
original_code=self._task.buggy_code,
|
|
443
|
+
diff=diff,
|
|
444
|
+
test_results=test_results,
|
|
445
|
+
test_output=(
|
|
446
|
+
"\n".join(
|
|
447
|
+
f"[{'✓' if r.passed else '✗'}] {r.name}: {r.output or r.error}"
|
|
448
|
+
for r in test_results
|
|
449
|
+
)
|
|
450
|
+
if test_results
|
|
451
|
+
else ""
|
|
452
|
+
),
|
|
453
|
+
tests_passed=passed,
|
|
454
|
+
tests_total=total,
|
|
455
|
+
all_tests_pass=all_pass,
|
|
456
|
+
score_so_far=score,
|
|
457
|
+
shaped_reward=shaped_reward,
|
|
458
|
+
step_count=self._state.step_count,
|
|
459
|
+
max_steps=self._state.max_steps,
|
|
460
|
+
steps_remaining=max(0, self._state.max_steps - self._state.step_count),
|
|
461
|
+
done=False,
|
|
462
|
+
task_id=self._task.id,
|
|
463
|
+
difficulty=self._task.difficulty,
|
|
464
|
+
bug_category=self._task.bug_category,
|
|
465
|
+
hints_used=self._state.hints_used,
|
|
466
|
+
hint_available=self._state.hints_used < len(self._task.hints),
|
|
467
|
+
feedback=feedback,
|
|
468
|
+
error_message=error_message,
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
def _parse_results(self, exec_results: list[ExecutionResult]) -> list[TestResult]:
|
|
472
|
+
"""Convert raw ExecutionResult list → TestResult list."""
|
|
473
|
+
assert self._task
|
|
474
|
+
results = []
|
|
475
|
+
for tc, er in zip(self._task.test_cases, exec_results, strict=True):
|
|
476
|
+
# Guard: er must be ExecutionResult not a list or other type
|
|
477
|
+
if not isinstance(er, ExecutionResult):
|
|
478
|
+
er = ExecutionResult(
|
|
479
|
+
passed=False,
|
|
480
|
+
exception=f"InternalError: expected ExecutionResult, got {type(er).__name__}",
|
|
481
|
+
)
|
|
482
|
+
results.append(
|
|
483
|
+
TestResult(
|
|
484
|
+
name=tc.name,
|
|
485
|
+
passed=er.passed,
|
|
486
|
+
output=er.stdout[:500] if er.stdout else "",
|
|
487
|
+
error=(er.exception or er.stderr)[:500],
|
|
488
|
+
runtime_ms=er.runtime_ms,
|
|
489
|
+
)
|
|
490
|
+
)
|
|
491
|
+
return results
|
|
492
|
+
|
|
493
|
+
def _check_terminal(
|
|
494
|
+
self, obs: CodeFixObservation
|
|
495
|
+
) -> tuple[bool, bool, TerminationReason | None]:
|
|
496
|
+
"""Returns (done, truncated, reason)."""
|
|
497
|
+
if obs.all_tests_pass:
|
|
498
|
+
return True, False, TerminationReason.SOLVED
|
|
499
|
+
if obs.done:
|
|
500
|
+
return True, False, TerminationReason.SUBMITTED
|
|
501
|
+
if self._state.step_count >= self._state.max_steps:
|
|
502
|
+
return False, True, TerminationReason.MAX_STEPS
|
|
503
|
+
return False, False, None
|