printf-sourav commited on
Commit
f13d613
·
1 Parent(s): 07d1d65

Completed

Browse files
Files changed (44) hide show
  1. .dockerignore +11 -0
  2. .env.example +13 -0
  3. Dockerfile +2 -4
  4. README.md +16 -4
  5. artifacts/metrics.json +7 -5
  6. artifacts/reward_curve.csv +3 -1
  7. artifacts/success_rate.csv +1 -1
  8. env/__init__.py +2 -1
  9. env/__pycache__/__init__.cpython-312.pyc +0 -0
  10. env/__pycache__/anti_hacking.cpython-312.pyc +0 -0
  11. env/__pycache__/hidden_tests.cpython-312.pyc +0 -0
  12. env/__pycache__/rewards.cpython-312.pyc +0 -0
  13. env/anti_hacking.py +39 -0
  14. env/environment.py +780 -0
  15. env/graders/__pycache__/__init__.cpython-312.pyc +0 -0
  16. env/graders/__pycache__/deterministic.cpython-312.pyc +0 -0
  17. env/graders/__pycache__/llm_judge.cpython-312.pyc +0 -0
  18. env/rewards.py +13 -4
  19. env/tasks/__init__.py +36 -0
  20. env/tasks/easy.py +113 -0
  21. env/tasks/hard.py +167 -0
  22. env/tasks/medium.py +139 -0
  23. env/tasks/task_types.py +21 -0
  24. inference.py +105 -422
  25. inference/__pycache__/__init__.cpython-312.pyc +0 -0
  26. inference/__pycache__/metrics.cpython-312.pyc +0 -0
  27. inference/__pycache__/model_wrapper.cpython-312.pyc +0 -0
  28. inference/__pycache__/prompts.cpython-312.pyc +0 -0
  29. inference/__pycache__/visualize.cpython-312.pyc +0 -0
  30. inference/model_wrapper.py +27 -21
  31. inference/prompts.py +50 -5
  32. openenv.yaml +34 -52
  33. pyproject.toml +1 -3
  34. requirements.txt +1 -3
  35. server/__pycache__/__init__.cpython-312.pyc +0 -0
  36. server/__pycache__/app.cpython-312.pyc +0 -0
  37. server/app.py +76 -146
  38. tests/__pycache__/test_day2_engine.cpython-312.pyc +0 -0
  39. tests/__pycache__/test_inference.cpython-312.pyc +0 -0
  40. tests/__pycache__/test_judge.cpython-312.pyc +0 -0
  41. tests/__pycache__/test_server_api.cpython-312.pyc +0 -0
  42. tests/test_env.py +113 -0
  43. tests/test_inference.py +1 -1
  44. validate-submission.sh +46 -261
.dockerignore ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .git/
2
+ .venv/
3
+ .pytest_cache/
4
+ __pycache__/
5
+ *.py[cod]
6
+ *.log
7
+ .env
8
+ artifacts/
9
+ tests/
10
+ uv.lock
11
+ migrated_from_cicd-debugger-env-2/
.env.example ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ API_BASE_URL=https://router.huggingface.co/v1
2
+ MODEL_NAME=Qwen/Qwen2.5-72B-Instruct
3
+ HF_TOKEN=<your_hf_or_api_token>
4
+
5
+ # Optional runtime knobs
6
+ LOCAL_IMAGE_NAME=
7
+ MY_ENV_V4_TASK=easy-command-typo
8
+ MY_ENV_V4_BENCHMARK=cicd_debugger_env
9
+ MAX_STEPS=8
10
+ TEMPERATURE=0.2
11
+ MAX_TOKENS=120
12
+ SUCCESS_SCORE_THRESHOLD=0.1
13
+ OFFLINE_INFERENCE=0
Dockerfile CHANGED
@@ -1,4 +1,4 @@
1
- FROM python:3.12-slim
2
 
3
  WORKDIR /app
4
 
@@ -7,9 +7,7 @@ RUN pip install --no-cache-dir -r requirements.txt
7
 
8
  COPY . .
9
 
10
- ENV OFFLINE_INFERENCE=1
11
  ENV PORT=7860
12
-
13
  EXPOSE 7860
14
 
15
- CMD ["python", "-m", "uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", "7860"]
 
1
+ FROM python:3.10-slim
2
 
3
  WORKDIR /app
4
 
 
7
 
8
  COPY . .
9
 
 
10
  ENV PORT=7860
 
11
  EXPOSE 7860
12
 
13
+ CMD ["python", "-m", "uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -108,7 +108,18 @@ RewardCalculator integrates:
108
  ### 5.1 Prompt and Model Layers
109
 
110
  - inference/prompts.py: stable prompt templates and fallback action heuristics
111
- - inference/model_wrapper.py: OpenAI-client action generation, candidate generation, and safe fallback
 
 
 
 
 
 
 
 
 
 
 
112
 
113
  ### 5.2 Metrics and Artifacts
114
 
@@ -129,7 +140,7 @@ Required output format:
129
 
130
  - [START] task=... env=... model=...
131
  - [STEP] step=<n> action=... reward=0.00 done=<true|false> error=<msg|null>
132
- - [END] success=<true|false> steps=<n> rewards=<r1,r2,...>
133
 
134
  Rules enforced:
135
 
@@ -140,7 +151,7 @@ Rules enforced:
140
 
141
  ## 6. Task Coverage
142
 
143
- The project includes 13 CI-fix tasks spanning:
144
 
145
  - easy: syntax and typo fixes
146
  - medium: dependency/env/cache/permissions issues
@@ -160,7 +171,8 @@ Environment variables:
160
  export API_BASE_URL="https://router.huggingface.co/v1"
161
  export MODEL_NAME="Qwen/Qwen2.5-72B-Instruct"
162
  export HF_TOKEN="<your_token>"
163
- export LOCAL_IMAGE_NAME="<your_env_image_name>"
 
164
  ```
165
 
166
  ## 8. Run Inference
 
108
  ### 5.1 Prompt and Model Layers
109
 
110
  - inference/prompts.py: stable prompt templates and fallback action heuristics
111
+ - inference/model_wrapper.py: OpenAI client action generation, candidate generation, and safe fallback
112
+
113
+ Canonical action tools used by environment and inference:
114
+
115
+ - read_file
116
+ - read_logs
117
+ - analyze_error
118
+ - edit_config
119
+ - run_pipeline_stage
120
+ - run_tests
121
+ - validate_fix
122
+ - submit_solution
123
 
124
  ### 5.2 Metrics and Artifacts
125
 
 
140
 
141
  - [START] task=... env=... model=...
142
  - [STEP] step=<n> action=... reward=0.00 done=<true|false> error=<msg|null>
143
+ - [END] success=<true|false> steps=<n> score=<0.000> rewards=<r1,r2,...>
144
 
145
  Rules enforced:
146
 
 
151
 
152
  ## 6. Task Coverage
153
 
154
+ The project includes 9 CI-fix tasks spanning:
155
 
156
  - easy: syntax and typo fixes
157
  - medium: dependency/env/cache/permissions issues
 
171
  export API_BASE_URL="https://router.huggingface.co/v1"
172
  export MODEL_NAME="Qwen/Qwen2.5-72B-Instruct"
173
  export HF_TOKEN="<your_token>"
174
+ # Optional, only if your inference spins environments from local images.
175
+ export LOCAL_IMAGE_NAME="<local_env_image_name>"
176
  ```
177
 
178
  ## 8. Run Inference
artifacts/metrics.json CHANGED
@@ -1,7 +1,9 @@
1
  {
2
- "average_reward": 1.0,
3
- "failure_reasons": {},
4
- "steps": 1,
5
- "success_rate": 1.0,
6
- "total_reward": 1.0
 
 
7
  }
 
1
  {
2
+ "average_reward": 0.4111,
3
+ "failure_reasons": {
4
+ "max_steps_reached": 1
5
+ },
6
+ "steps": 3,
7
+ "success_rate": 0.3333,
8
+ "total_reward": 1.2332
9
  }
artifacts/reward_curve.csv CHANGED
@@ -1,2 +1,4 @@
1
  step,reward
2
- 1,1.0000
 
 
 
1
  step,reward
2
+ 1,0.3016
3
+ 2,0.3616
4
+ 3,0.5700
artifacts/success_rate.csv CHANGED
@@ -1,2 +1,2 @@
1
  episode,success,success_rate
2
- 1,1,1.0000
 
1
  episode,success,success_rate
2
+ 1,0,0.0000
env/__init__.py CHANGED
@@ -1,3 +1,4 @@
1
  from env.rewards import RewardCalculator
 
2
 
3
- __all__ = ["RewardCalculator"]
 
1
  from env.rewards import RewardCalculator
2
+ from env.environment import CICDDebuggerEnvironment, MAX_STEPS, REQUIRED_TOOLS
3
 
4
+ __all__ = ["RewardCalculator", "CICDDebuggerEnvironment", "REQUIRED_TOOLS", "MAX_STEPS"]
env/__pycache__/__init__.cpython-312.pyc DELETED
Binary file (236 Bytes)
 
env/__pycache__/anti_hacking.cpython-312.pyc DELETED
Binary file (7.93 kB)
 
env/__pycache__/hidden_tests.cpython-312.pyc DELETED
Binary file (3.4 kB)
 
env/__pycache__/rewards.cpython-312.pyc DELETED
Binary file (7.94 kB)
 
env/anti_hacking.py CHANGED
@@ -24,6 +24,13 @@ class AntiHackingDetector:
24
  r"\btrue\b\s*#?\s*force",
25
  )
26
 
 
 
 
 
 
 
 
27
  BROKEN_COMMAND_PATTERNS = (
28
  r"\bnpm\s+tset\b",
29
  r"\bpyhton\b",
@@ -47,6 +54,19 @@ class AntiHackingDetector:
47
  base = 0.15 if has_real_test_cmd else 0.25
48
  return -min(base * hits, 0.70)
49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  def penalty_breaking_pipeline(self, previous_config: str, new_config: str) -> float:
51
  if not previous_config or not new_config:
52
  return 0.0
@@ -104,6 +124,20 @@ class AntiHackingDetector:
104
  return -0.50
105
  return 0.0
106
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  def total_penalty(
108
  self,
109
  current_config: str = "",
@@ -112,10 +146,14 @@ class AntiHackingDetector:
112
  changed_files_count: int = 0,
113
  changed_lines_count: int = 0,
114
  step_count: int = 0,
 
 
115
  ) -> float:
116
  total = 0.0
 
117
  total += self.penalty_stage_skipping(current_config)
118
  total += self.penalty_fake_success(current_config)
 
119
  total += self.penalty_breaking_pipeline(previous_config, current_config)
120
  total += self.penalty_excessive_edits(
121
  edit_count=edit_count,
@@ -123,6 +161,7 @@ class AntiHackingDetector:
123
  changed_lines_count=changed_lines_count,
124
  )
125
  total += self.penalty_timeout_abuse(step_count)
 
126
 
127
  return round(total, 4)
128
 
 
24
  r"\btrue\b\s*#?\s*force",
25
  )
26
 
27
+ HARDCODED_OUTPUT_PATTERNS = (
28
+ r"\bstatus\s*:\s*success\b",
29
+ r"\bresult\s*:\s*passed\b",
30
+ r"\btests?_passed\s*=\s*true\b",
31
+ r"\bforce_success\b",
32
+ )
33
+
34
  BROKEN_COMMAND_PATTERNS = (
35
  r"\bnpm\s+tset\b",
36
  r"\bpyhton\b",
 
54
  base = 0.15 if has_real_test_cmd else 0.25
55
  return -min(base * hits, 0.70)
56
 
57
+ def penalty_hardcoded_outputs(self, config_text: str) -> float:
58
+ hits = self._count_hits(config_text, self.HARDCODED_OUTPUT_PATTERNS)
59
+ if hits == 0:
60
+ return 0.0
61
+ return -min(0.18 * hits, 0.60)
62
+
63
+ def penalty_invalid_config(self, config_text: str) -> float:
64
+ if not (config_text or "").strip():
65
+ return -0.30
66
+ if not self._is_yaml_valid(config_text):
67
+ return -0.35
68
+ return 0.0
69
+
70
  def penalty_breaking_pipeline(self, previous_config: str, new_config: str) -> float:
71
  if not previous_config or not new_config:
72
  return 0.0
 
124
  return -0.50
125
  return 0.0
126
 
127
+ def penalty_bruteforce_attempts(self, consecutive_edit_actions: int, failed_validations: int) -> float:
128
+ penalty = 0.0
129
+ if consecutive_edit_actions >= 6:
130
+ penalty -= 0.25
131
+ if consecutive_edit_actions >= 10:
132
+ penalty -= 0.35
133
+
134
+ if failed_validations >= 3:
135
+ penalty -= 0.20
136
+ if failed_validations >= 6:
137
+ penalty -= 0.35
138
+
139
+ return max(-0.80, penalty)
140
+
141
  def total_penalty(
142
  self,
143
  current_config: str = "",
 
146
  changed_files_count: int = 0,
147
  changed_lines_count: int = 0,
148
  step_count: int = 0,
149
+ consecutive_edit_actions: int = 0,
150
+ failed_validations: int = 0,
151
  ) -> float:
152
  total = 0.0
153
+ total += self.penalty_invalid_config(current_config)
154
  total += self.penalty_stage_skipping(current_config)
155
  total += self.penalty_fake_success(current_config)
156
+ total += self.penalty_hardcoded_outputs(current_config)
157
  total += self.penalty_breaking_pipeline(previous_config, current_config)
158
  total += self.penalty_excessive_edits(
159
  edit_count=edit_count,
 
161
  changed_lines_count=changed_lines_count,
162
  )
163
  total += self.penalty_timeout_abuse(step_count)
164
+ total += self.penalty_bruteforce_attempts(consecutive_edit_actions, failed_validations)
165
 
166
  return round(total, 4)
167
 
env/environment.py ADDED
@@ -0,0 +1,780 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from difflib import SequenceMatcher
5
+ import random
6
+ import re
7
+ from typing import Any
8
+
9
+ import yaml
10
+
11
+ from env.rewards import RewardCalculator
12
+ from env.tasks import get_task_by_id, get_tasks_by_difficulty
13
+ from env.tasks.task_types import CICDTask
14
+
15
+
16
+ REQUIRED_TOOLS = [
17
+ "read_file",
18
+ "read_logs",
19
+ "analyze_error",
20
+ "edit_config",
21
+ "run_pipeline_stage",
22
+ "run_tests",
23
+ "validate_fix",
24
+ "submit_solution",
25
+ ]
26
+
27
+ MAX_STEPS = 30
28
+
29
+
30
+ @dataclass
31
+ class EnvironmentState:
32
+ task: CICDTask
33
+ current_config: str
34
+ previous_config: str
35
+ step_count: int = 0
36
+ done: bool = False
37
+ progress_flags: dict[str, bool] = field(default_factory=dict)
38
+ file_modification_count: int = 0
39
+ total_changed_lines: int = 0
40
+ hidden_test_pass_rate: float = 0.0
41
+ action_history: list[str] = field(default_factory=list)
42
+ stage_results: dict[str, bool] = field(default_factory=dict)
43
+ failed_validations: int = 0
44
+ consecutive_edit_actions: int = 0
45
+ current_logs: str = ""
46
+ last_error: str = ""
47
+ last_action_error: str | None = None
48
+ last_info: dict[str, Any] = field(default_factory=dict)
49
+
50
+
51
+ class CICDDebuggerEnvironment:
52
+ """RL-style CI/CD debugging environment with strict tool-based actions."""
53
+
54
+ def __init__(
55
+ self,
56
+ max_steps: int = MAX_STEPS,
57
+ seed: int | None = None,
58
+ llm_judge: Any | None = None,
59
+ ) -> None:
60
+ self.max_steps = max(1, int(max_steps))
61
+ self.random = random.Random(seed)
62
+ self.reward_calculator = RewardCalculator(llm_judge=llm_judge)
63
+ self.state: EnvironmentState | None = None
64
+
65
+ async def reset(self, task_id: str | None = None, difficulty: str | None = None) -> dict[str, Any]:
66
+ task = self._select_task(task_id=task_id, difficulty=difficulty)
67
+
68
+ self.state = EnvironmentState(
69
+ task=task,
70
+ current_config=task.broken_config,
71
+ previous_config=task.broken_config,
72
+ progress_flags={tool: False for tool in REQUIRED_TOOLS},
73
+ current_logs=task.logs,
74
+ last_error=task.error_message,
75
+ )
76
+
77
+ return self._build_observation()
78
+
79
+ async def step(self, action: Any) -> tuple[dict[str, Any], float, bool, dict[str, Any]]:
80
+ if self.state is None:
81
+ raise RuntimeError("Environment not initialized. Call reset() first.")
82
+
83
+ if self.state.done:
84
+ return self._build_observation(), 0.0, True, {
85
+ "tool": "none",
86
+ "message": "episode already completed",
87
+ "error": None,
88
+ }
89
+
90
+ tool, payload = self._parse_action(action)
91
+ self.state.step_count += 1
92
+ self.state.previous_config = self.state.current_config
93
+ self.state.action_history.append(tool)
94
+ self.state.last_action_error = None
95
+
96
+ info: dict[str, Any] = {
97
+ "tool": tool,
98
+ "message": "",
99
+ "error": None,
100
+ }
101
+ changed_lines = 0
102
+
103
+ result: dict[str, Any] = {
104
+ "previous_config": self.state.previous_config,
105
+ "current_config": self.state.current_config,
106
+ "fixed_config": self.state.current_config,
107
+ "expected_config": self.state.task.expected_config,
108
+ "error": self.state.last_error,
109
+ "logs_analyzed": False,
110
+ "error_diagnosed": False,
111
+ "fix_proposed": False,
112
+ "pipeline_run": False,
113
+ "tests_passed": False,
114
+ "command_succeeded": False,
115
+ "changed_files_count": 0,
116
+ "changed_lines_count": 0,
117
+ "edit_count": {
118
+ "changed_files_count": self.state.file_modification_count,
119
+ "changed_lines_count": self.state.total_changed_lines,
120
+ },
121
+ "deterministic_score": None,
122
+ "hidden_test_pass_rate": None,
123
+ "judge_scores": None,
124
+ "hacking_attempt": False,
125
+ }
126
+
127
+ if tool not in REQUIRED_TOOLS:
128
+ info["message"] = "unsupported action tool"
129
+ info["error"] = f"tool '{tool}' is not allowed"
130
+ self.state.last_action_error = str(info["error"])
131
+ elif tool == "read_file":
132
+ self.state.progress_flags[tool] = True
133
+ result["command_succeeded"] = True
134
+ info["message"] = "returned current workflow config"
135
+ self.state.current_logs = self.state.current_config
136
+ self.state.consecutive_edit_actions = 0
137
+ elif tool == "read_logs":
138
+ self.state.progress_flags[tool] = True
139
+ result["logs_analyzed"] = True
140
+ result["command_succeeded"] = True
141
+ info["message"] = "returned pipeline failure logs"
142
+ self.state.current_logs = self.state.task.logs
143
+ self.state.consecutive_edit_actions = 0
144
+ elif tool == "analyze_error":
145
+ self.state.progress_flags[tool] = True
146
+ result["error_diagnosed"] = True
147
+ result["command_succeeded"] = True
148
+ root_cause = self._detect_root_cause(self.state.current_config, self.state.task)
149
+ info["message"] = f"root cause: {root_cause}"
150
+ self.state.current_logs = f"analysis result: {root_cause}"
151
+ self.state.consecutive_edit_actions = 0
152
+ elif tool == "edit_config":
153
+ self.state.progress_flags[tool] = True
154
+ updated_config, summary = self._apply_edit(self.state.current_config, payload, self.state.task)
155
+ changed_lines = self._count_changed_lines(self.state.current_config, updated_config)
156
+
157
+ if changed_lines > 0:
158
+ self.state.current_config = updated_config
159
+ self.state.file_modification_count += 1
160
+ self.state.total_changed_lines += changed_lines
161
+ result["fix_proposed"] = True
162
+ result["command_succeeded"] = True
163
+ info["message"] = summary
164
+ self.state.current_logs = f"edit applied: {summary}"
165
+ else:
166
+ result["command_succeeded"] = False
167
+ info["message"] = "no config changes applied"
168
+ info["error"] = "edit_config did not modify workflow"
169
+ self.state.last_action_error = str(info["error"])
170
+ self.state.current_logs = "edit action produced no changes"
171
+
172
+ self.state.consecutive_edit_actions += 1
173
+ elif tool == "run_pipeline_stage":
174
+ self.state.progress_flags[tool] = True
175
+ stage = self._extract_stage(payload, fallback=self.state.task.failure_stage)
176
+ success, stage_logs = self._simulate_stage(self.state.current_config, stage, self.state.task)
177
+ self.state.stage_results[stage] = success
178
+ result["pipeline_run"] = True
179
+ result["command_succeeded"] = success
180
+ info["message"] = f"stage '{stage}' {'passed' if success else 'failed'}"
181
+ if not success:
182
+ info["error"] = stage_logs
183
+ self.state.last_action_error = stage_logs
184
+ self.state.last_error = stage_logs
185
+ self.state.current_logs = stage_logs
186
+ self.state.consecutive_edit_actions = 0
187
+ elif tool == "run_tests":
188
+ self.state.progress_flags[tool] = True
189
+ tests_passed, test_logs = self._run_tests(self.state.current_config, self.state.task)
190
+ result["pipeline_run"] = True
191
+ result["tests_passed"] = tests_passed
192
+ result["command_succeeded"] = tests_passed
193
+ info["message"] = "tests passed" if tests_passed else "tests failed"
194
+ if not tests_passed:
195
+ info["error"] = test_logs
196
+ self.state.last_action_error = test_logs
197
+ self.state.last_error = test_logs
198
+ self.state.current_logs = test_logs
199
+ self.state.consecutive_edit_actions = 0
200
+ elif tool == "validate_fix":
201
+ self.state.progress_flags[tool] = True
202
+ validation = self._validate_current_fix(self.state)
203
+ result.update(validation)
204
+ result["pipeline_run"] = True
205
+ is_valid = bool(validation.get("is_valid"))
206
+ result["command_succeeded"] = is_valid
207
+
208
+ if not is_valid:
209
+ self.state.failed_validations += 1
210
+ info["error"] = str(validation.get("summary", "validation failed"))
211
+ self.state.last_action_error = str(info["error"])
212
+
213
+ info["message"] = "validation passed" if is_valid else "validation failed"
214
+ self.state.hidden_test_pass_rate = float(validation.get("hidden_test_pass_rate") or 0.0)
215
+ self.state.current_logs = str(validation.get("summary", "validation complete"))
216
+ self.state.consecutive_edit_actions = 0
217
+ elif tool == "submit_solution":
218
+ validation = self._validate_current_fix(self.state)
219
+ result.update(validation)
220
+ result["pipeline_run"] = True
221
+ self.state.progress_flags[tool] = True
222
+ accepted = bool(validation.get("is_valid"))
223
+ result["command_succeeded"] = accepted
224
+
225
+ if accepted:
226
+ self.state.done = True
227
+ info["message"] = "solution accepted"
228
+ self.state.current_logs = "submission accepted"
229
+ else:
230
+ self.state.failed_validations += 1
231
+ info["message"] = "solution rejected"
232
+ info["error"] = "submission failed quality checks"
233
+ self.state.last_action_error = str(info["error"])
234
+ self.state.current_logs = str(validation.get("summary", "submission rejected"))
235
+
236
+ self.state.hidden_test_pass_rate = float(validation.get("hidden_test_pass_rate") or 0.0)
237
+ self.state.consecutive_edit_actions = 0
238
+
239
+ result["hacking_attempt"] = self._detect_hacking_attempt(tool, payload, self.state.current_config)
240
+ result["current_config"] = self.state.current_config
241
+ result["fixed_config"] = self.state.current_config
242
+ result["changed_files_count"] = 1 if changed_lines > 0 else 0
243
+ result["changed_lines_count"] = changed_lines
244
+ result["edit_count"] = {
245
+ "changed_files_count": self.state.file_modification_count,
246
+ "changed_lines_count": self.state.total_changed_lines,
247
+ }
248
+
249
+ if info["error"]:
250
+ self.state.last_error = str(info["error"])
251
+ result["error"] = self.state.last_error
252
+
253
+ if self.state.step_count >= self.max_steps and not self.state.done:
254
+ self.state.done = True
255
+ if not info["error"]:
256
+ info["error"] = "max_steps_reached"
257
+ info["message"] = "max steps reached"
258
+
259
+ reward = self.reward_calculator.calculate_step_reward(
260
+ state={
261
+ "step_count": self.state.step_count,
262
+ "previous_config": self.state.previous_config,
263
+ "expected_config": self.state.task.expected_config,
264
+ "original_config": self.state.task.broken_config,
265
+ "error": self.state.last_error,
266
+ "changed_files_count": self.state.file_modification_count,
267
+ "changed_lines_count": self.state.total_changed_lines,
268
+ "consecutive_edit_actions": self.state.consecutive_edit_actions,
269
+ "failed_validations": self.state.failed_validations,
270
+ },
271
+ action=tool,
272
+ result=result,
273
+ original_config=self.state.task.broken_config,
274
+ fixed_config=self.state.current_config,
275
+ error_message=self.state.last_error,
276
+ expected_config=self.state.task.expected_config,
277
+ metadata=self.state.task.metadata,
278
+ )
279
+
280
+ self.state.last_info = info
281
+ observation = self._build_observation()
282
+ done = bool(self.state.done)
283
+
284
+ return observation, float(reward), done, info
285
+
286
+ async def close(self) -> None:
287
+ return None
288
+
289
+ def get_state(self) -> dict[str, Any]:
290
+ if self.state is None:
291
+ return {"initialized": False}
292
+
293
+ return {
294
+ "initialized": True,
295
+ "task_id": self.state.task.task_id,
296
+ "difficulty": self.state.task.difficulty,
297
+ "actual_bug": self.state.task.actual_bug,
298
+ "correct_solution": self.state.task.expected_config,
299
+ "failure_stage": self.state.task.failure_stage,
300
+ "step_count": self.state.step_count,
301
+ "done": self.state.done,
302
+ "progress_flags": dict(self.state.progress_flags),
303
+ "file_modification_count": self.state.file_modification_count,
304
+ "total_changed_lines": self.state.total_changed_lines,
305
+ "hidden_test_pass_rate": self.state.hidden_test_pass_rate,
306
+ "stage_results": dict(self.state.stage_results),
307
+ "failed_validations": self.state.failed_validations,
308
+ "last_action_error": self.state.last_action_error,
309
+ "last_error": self.state.last_error,
310
+ }
311
+
312
+ def _build_observation(self) -> dict[str, Any]:
313
+ if self.state is None:
314
+ raise RuntimeError("Environment not initialized")
315
+
316
+ return {
317
+ "task_id": self.state.task.task_id,
318
+ "difficulty": self.state.task.difficulty,
319
+ "failure_stage": self.state.task.failure_stage,
320
+ "actual_bug": self.state.task.actual_bug,
321
+ "config": self.state.current_config,
322
+ "logs": self.state.current_logs,
323
+ "error_message": self.state.last_error,
324
+ "available_tools": list(REQUIRED_TOOLS),
325
+ "progress_flags": dict(self.state.progress_flags),
326
+ "file_modification_count": self.state.file_modification_count,
327
+ "hidden_test_pass_rate": self.state.hidden_test_pass_rate,
328
+ "step_count": self.state.step_count,
329
+ "last_action_error": self.state.last_action_error,
330
+ }
331
+
332
+ def _select_task(self, task_id: str | None, difficulty: str | None) -> CICDTask:
333
+ if task_id:
334
+ task = get_task_by_id(task_id)
335
+ if task is None:
336
+ raise ValueError(f"Unknown task_id: {task_id}")
337
+ return task
338
+
339
+ filtered = get_tasks_by_difficulty(difficulty)
340
+ if not filtered:
341
+ raise ValueError(f"No tasks available for difficulty: {difficulty}")
342
+
343
+ return self.random.choice(filtered)
344
+
345
+ def _parse_action(self, action: Any) -> tuple[str, dict[str, Any]]:
346
+ tool = ""
347
+ payload: dict[str, Any] = {}
348
+
349
+ if isinstance(action, str):
350
+ raw = action.strip()
351
+ if ":" in raw:
352
+ tool_part, payload_part = raw.split(":", 1)
353
+ tool = tool_part.strip().lower()
354
+ payload = {"raw": payload_part.strip()}
355
+ else:
356
+ parts = raw.split(maxsplit=1)
357
+ tool = parts[0].strip().lower() if parts else ""
358
+ if len(parts) > 1:
359
+ payload = {"raw": parts[1].strip()}
360
+ elif isinstance(action, dict):
361
+ tool = str(action.get("tool") or action.get("action_type") or "").strip().lower()
362
+ incoming_payload = action.get("payload")
363
+ if isinstance(incoming_payload, dict):
364
+ payload = dict(incoming_payload)
365
+ elif incoming_payload is not None:
366
+ payload = {"raw": str(incoming_payload)}
367
+ elif "input" in action:
368
+ payload = {"raw": str(action.get("input") or "").strip()}
369
+
370
+ return tool, payload
371
+
372
+ def _extract_stage(self, payload: dict[str, Any], fallback: str) -> str:
373
+ direct_stage = str(payload.get("stage") or "").strip().lower()
374
+ if direct_stage in {"build", "test", "deploy"}:
375
+ return direct_stage
376
+
377
+ raw = str(payload.get("raw") or "").lower()
378
+ for stage in ("build", "test", "deploy"):
379
+ if stage in raw:
380
+ return stage
381
+
382
+ return fallback
383
+
384
+ def _detect_root_cause(self, config_text: str, task: CICDTask) -> str:
385
+ normalized = self._normalize(config_text)
386
+ broken_token = self._normalize(str(task.metadata.get("broken_token", "")))
387
+
388
+ if broken_token and broken_token in normalized:
389
+ return task.actual_bug
390
+
391
+ if not self._is_yaml_valid(config_text):
392
+ return "workflow YAML is invalid"
393
+
394
+ fixed_token = self._normalize(str(task.metadata.get("fixed_token", "")))
395
+ if fixed_token and fixed_token not in normalized:
396
+ return f"missing expected fix token: {task.metadata.get('fixed_token')}"
397
+
398
+ return "configuration still deviates from expected pipeline behavior"
399
+
400
+ def _apply_edit(self, current_config: str, payload: dict[str, Any], task: CICDTask) -> tuple[str, str]:
401
+ candidate = current_config
402
+ edits: list[str] = []
403
+
404
+ new_config = payload.get("new_config")
405
+ if isinstance(new_config, str) and new_config.strip():
406
+ return new_config.strip(), "applied payload new_config"
407
+
408
+ raw = str(payload.get("raw") or "")
409
+ raw_lower = raw.lower()
410
+
411
+ replace_match = re.search(
412
+ r"replace\s+['\"]?(.+?)['\"]?\s+with\s+['\"]?(.+?)['\"]?\s*$",
413
+ raw,
414
+ flags=re.IGNORECASE,
415
+ )
416
+ if replace_match:
417
+ old = replace_match.group(1).strip()
418
+ new = replace_match.group(2).strip()
419
+ if old and old in candidate:
420
+ candidate = candidate.replace(old, new)
421
+ edits.append(f"replaced '{old}' with '{new}'")
422
+
423
+ if "checkout" in raw_lower and "actions/checkout@v4" not in candidate:
424
+ updated = self._ensure_checkout(candidate)
425
+ if updated != candidate:
426
+ candidate = updated
427
+ edits.append("inserted actions/checkout@v4 step")
428
+
429
+ if "permissions" in raw_lower or "actions: write" in raw_lower:
430
+ updated = self._ensure_actions_write(candidate)
431
+ if updated != candidate:
432
+ candidate = updated
433
+ edits.append("added actions: write permission")
434
+
435
+ if not edits and any(token in raw_lower for token in ("yaml", "indent", "syntax")):
436
+ updated = self._repair_yaml(candidate, task.expected_config)
437
+ if updated != candidate:
438
+ candidate = updated
439
+ edits.append("repaired YAML structure")
440
+
441
+ broken_token = str(task.metadata.get("broken_token", ""))
442
+ fixed_token = str(task.metadata.get("fixed_token", ""))
443
+ if not edits and broken_token and fixed_token and broken_token in candidate:
444
+ occurrence_count = candidate.count(broken_token)
445
+
446
+ if occurrence_count > 1:
447
+ candidate = task.expected_config
448
+ edits.append("applied canonical fix for ambiguous token")
449
+ elif fixed_token.strip().endswith(":"):
450
+ expected_block = self._extract_expected_block(task.expected_config, fixed_token)
451
+ if expected_block and expected_block not in candidate:
452
+ candidate = candidate.replace(broken_token, f"{broken_token}\n{expected_block}", 1)
453
+ edits.append("inserted expected YAML block")
454
+ else:
455
+ candidate = candidate.replace(broken_token, fixed_token, 1)
456
+ edits.append("applied metadata token replacement")
457
+ else:
458
+ expected_line = self._find_line_containing(task.expected_config, fixed_token)
459
+ replacement = expected_line.strip() if expected_line else fixed_token
460
+ candidate = candidate.replace(broken_token, replacement, 1)
461
+ edits.append("applied metadata token replacement")
462
+
463
+ if not edits and fixed_token and fixed_token not in candidate and not broken_token:
464
+ updated = self._append_missing_token(candidate, fixed_token)
465
+ if updated != candidate:
466
+ candidate = updated
467
+ edits.append("appended expected token")
468
+
469
+ if not edits and any(token in raw_lower for token in ("expected config", "apply expected", "canonical fix")):
470
+ candidate = task.expected_config
471
+ edits.append("replaced with expected task config")
472
+
473
+ summary = "; ".join(edits) if edits else "no-op edit"
474
+ return candidate, summary
475
+
476
+ def _ensure_checkout(self, config_text: str) -> str:
477
+ if "actions/checkout@v4" in config_text:
478
+ return config_text
479
+
480
+ marker = "steps:\n"
481
+ insert = " - uses: actions/checkout@v4\n"
482
+ if marker in config_text:
483
+ return config_text.replace(marker, marker + insert, 1)
484
+
485
+ return config_text
486
+
487
+ def _ensure_actions_write(self, config_text: str) -> str:
488
+ if "actions: write" in config_text:
489
+ return config_text
490
+
491
+ if "permissions:" in config_text:
492
+ lines = config_text.splitlines()
493
+ out: list[str] = []
494
+ inserted = False
495
+ for line in lines:
496
+ out.append(line)
497
+ if line.strip().startswith("permissions:") and not inserted:
498
+ continue
499
+ if line.strip().startswith("contents:") and not inserted:
500
+ indent = line[: len(line) - len(line.lstrip(" "))]
501
+ out.append(f"{indent}actions: write")
502
+ inserted = True
503
+ if inserted:
504
+ return "\n".join(out)
505
+
506
+ return "permissions:\n actions: write\n" + config_text
507
+
508
+ def _append_missing_token(self, config_text: str, token: str) -> str:
509
+ if not token or token in config_text:
510
+ return config_text
511
+
512
+ lower_token = token.lower()
513
+ if "actions/checkout@v4" in lower_token:
514
+ return self._ensure_checkout(config_text)
515
+ if "actions: write" in lower_token:
516
+ return self._ensure_actions_write(config_text)
517
+
518
+ return config_text + "\n" + token
519
+
520
+ def _repair_yaml(self, current_config: str, expected_config: str) -> str:
521
+ if self._is_yaml_valid(current_config):
522
+ return current_config
523
+
524
+ if expected_config and self._is_yaml_valid(expected_config):
525
+ return expected_config
526
+
527
+ return current_config
528
+
529
+ def _find_line_containing(self, config_text: str, token: str) -> str | None:
530
+ target = (token or "").strip()
531
+ if not target:
532
+ return None
533
+
534
+ for line in (config_text or "").splitlines():
535
+ if target in line:
536
+ return line
537
+
538
+ return None
539
+
540
+ def _extract_expected_block(self, config_text: str, token: str) -> str:
541
+ lines = (config_text or "").splitlines()
542
+ target = (token or "").strip()
543
+ if not target:
544
+ return ""
545
+
546
+ for idx, line in enumerate(lines):
547
+ if target not in line:
548
+ continue
549
+
550
+ base_indent = len(line) - len(line.lstrip(" "))
551
+ block = [line]
552
+ for next_line in lines[idx + 1 :]:
553
+ if not next_line.strip():
554
+ break
555
+ next_indent = len(next_line) - len(next_line.lstrip(" "))
556
+ if next_indent <= base_indent:
557
+ break
558
+ block.append(next_line)
559
+ return "\n".join(block)
560
+
561
+ return ""
562
+
563
+ def _simulate_stage(self, config_text: str, stage: str, task: CICDTask) -> tuple[bool, str]:
564
+ if not self._is_yaml_valid(config_text):
565
+ return False, "invalid workflow YAML"
566
+
567
+ expected_has_stage = self._stage_exists(task.expected_config, stage)
568
+ current_has_stage = self._stage_exists(config_text, stage)
569
+
570
+ if expected_has_stage and not current_has_stage:
571
+ return False, f"required stage '{stage}' is missing"
572
+
573
+ if not expected_has_stage and not current_has_stage:
574
+ return True, f"{stage} stage not required for this task"
575
+
576
+ normalized = self._normalize(config_text)
577
+ broken_token = self._normalize(str(task.metadata.get("broken_token", "")))
578
+ fixed_token = self._normalize(str(task.metadata.get("fixed_token", "")))
579
+
580
+ if self._contains_hacking_pattern(config_text):
581
+ return False, "unsafe shortcut pattern detected"
582
+
583
+ if stage == task.failure_stage and broken_token and broken_token in normalized:
584
+ return False, task.logs
585
+
586
+ if stage == task.failure_stage and fixed_token and fixed_token not in normalized:
587
+ return False, task.logs
588
+
589
+ commands = self._extract_commands(config_text)
590
+
591
+ if stage == "build":
592
+ build_tokens = ("npm ci", "npm install", "pip install", "go build", "mvn", "yarn install", "pnpm install")
593
+ if not any(any(token in cmd for token in build_tokens) for cmd in commands):
594
+ return False, "build stage has no install/build command"
595
+
596
+ if stage == "test":
597
+ test_tokens = ("npm test", "pytest", "go test", "mvn test", "yarn test", "pnpm test")
598
+ if not any(any(token in cmd for token in test_tokens) for cmd in commands):
599
+ return False, "test stage has no test command"
600
+
601
+ if stage == "deploy":
602
+ deploy_tokens = ("deploy", "publish", "upload-artifact", "release")
603
+ if not any(any(token in cmd for token in deploy_tokens) for cmd in commands):
604
+ return False, "deploy stage has no deployment command"
605
+
606
+ return True, f"{stage} stage passed"
607
+
608
+ def _run_tests(self, config_text: str, task: CICDTask) -> tuple[bool, str]:
609
+ if self._stage_exists(task.expected_config, "build"):
610
+ build_ok, build_logs = self._simulate_stage(config_text, "build", task)
611
+ if not build_ok:
612
+ return False, build_logs
613
+
614
+ if self._stage_exists(task.expected_config, "test"):
615
+ test_ok, test_logs = self._simulate_stage(config_text, "test", task)
616
+ if not test_ok:
617
+ return False, test_logs
618
+
619
+ similarity = SequenceMatcher(None, self._normalize(config_text), self._normalize(task.expected_config)).ratio()
620
+ if similarity < 0.45:
621
+ return False, "tests failed: fix diverges significantly from expected pipeline"
622
+
623
+ return True, "tests passed"
624
+
625
+ def _validate_current_fix(self, state: EnvironmentState) -> dict[str, Any]:
626
+ current = state.current_config
627
+ task = state.task
628
+
629
+ deterministic_score = self.reward_calculator.deterministic_grader.grade(
630
+ current,
631
+ task.expected_config,
632
+ metadata=task.metadata,
633
+ )
634
+ hidden_test_pass_rate = self.reward_calculator.hidden_test_runner.evaluate_fix(
635
+ fixed_config=current,
636
+ expected_config=task.expected_config,
637
+ metadata=task.metadata,
638
+ )
639
+
640
+ judge_scores = None
641
+ if self.reward_calculator.llm_judge is not None:
642
+ try:
643
+ judge_scores = self.reward_calculator.llm_judge.evaluate_fix(
644
+ task.broken_config,
645
+ current,
646
+ state.last_error,
647
+ )
648
+ except Exception:
649
+ judge_scores = None
650
+
651
+ tests_passed, test_logs = self._run_tests(current, task)
652
+
653
+ stage_ok, stage_logs = self._simulate_stage(current, task.failure_stage, task)
654
+
655
+ broken_token = self._normalize(str(task.metadata.get("broken_token", "")))
656
+ fixed_token = self._normalize(str(task.metadata.get("fixed_token", "")))
657
+ normalized_current = self._normalize(current)
658
+
659
+ token_constraints_met = True
660
+ if broken_token and broken_token in normalized_current:
661
+ token_constraints_met = False
662
+ if fixed_token and fixed_token not in normalized_current:
663
+ token_constraints_met = False
664
+
665
+ judge_average = 1.0
666
+ if isinstance(judge_scores, dict):
667
+ judge_average = (
668
+ float(judge_scores.get("correctness", 0.0))
669
+ + float(judge_scores.get("minimalism", 0.0))
670
+ + float(judge_scores.get("quality", 0.0))
671
+ ) / 3.0
672
+
673
+ is_valid = (
674
+ tests_passed
675
+ and stage_ok
676
+ and token_constraints_met
677
+ and deterministic_score >= 0.72
678
+ and hidden_test_pass_rate >= 0.65
679
+ and judge_average >= 0.5
680
+ )
681
+
682
+ summary = (
683
+ f"deterministic={deterministic_score:.2f}; "
684
+ f"hidden={hidden_test_pass_rate:.2f}; "
685
+ f"tests={'pass' if tests_passed else 'fail'}; "
686
+ f"failure_stage={'pass' if stage_ok else 'fail'}"
687
+ )
688
+ if not tests_passed:
689
+ summary = test_logs
690
+ elif not stage_ok:
691
+ summary = stage_logs
692
+ elif not token_constraints_met:
693
+ summary = "validation failed: required bug-fix token constraints not satisfied"
694
+
695
+ return {
696
+ "deterministic_score": deterministic_score,
697
+ "hidden_test_pass_rate": hidden_test_pass_rate,
698
+ "judge_scores": judge_scores,
699
+ "tests_passed": tests_passed,
700
+ "is_valid": is_valid,
701
+ "summary": summary,
702
+ }
703
+
704
+ def _detect_hacking_attempt(self, tool: str, payload: dict[str, Any], config_text: str) -> bool:
705
+ payload_text = str(payload.get("raw") or "")
706
+ brute_force_detected = bool(
707
+ self.state and (self.state.consecutive_edit_actions >= 6 or self.state.failed_validations >= 3)
708
+ )
709
+
710
+ return (
711
+ brute_force_detected
712
+ or self._contains_hacking_pattern(payload_text)
713
+ or self._contains_hacking_pattern(config_text)
714
+ )
715
+
716
+ def _contains_hacking_pattern(self, text: str) -> bool:
717
+ if not text:
718
+ return False
719
+
720
+ patterns = (
721
+ *self.reward_calculator.anti_hacking_detector.STAGE_SKIP_PATTERNS,
722
+ *self.reward_calculator.anti_hacking_detector.FAKE_SUCCESS_PATTERNS,
723
+ *self.reward_calculator.anti_hacking_detector.HARDCODED_OUTPUT_PATTERNS,
724
+ )
725
+ return any(re.search(pattern, text, flags=re.IGNORECASE) for pattern in patterns)
726
+
727
+ def _extract_commands(self, config_text: str) -> list[str]:
728
+ commands: list[str] = []
729
+ for raw_line in config_text.splitlines():
730
+ line = raw_line.strip().lower()
731
+ if "- run:" in line:
732
+ commands.append(line.replace("- run:", "").strip())
733
+ elif line.startswith("- run "):
734
+ commands.append(line.replace("- run ", "", 1).strip())
735
+ return commands
736
+
737
+ def _is_yaml_valid(self, config_text: str) -> bool:
738
+ try:
739
+ parsed = yaml.safe_load(config_text)
740
+ except yaml.YAMLError:
741
+ return False
742
+ return isinstance(parsed, dict)
743
+
744
+ def _stage_exists(self, config_text: str, stage: str) -> bool:
745
+ try:
746
+ parsed = yaml.safe_load(config_text)
747
+ except yaml.YAMLError:
748
+ return False
749
+
750
+ if not isinstance(parsed, dict):
751
+ return False
752
+
753
+ jobs = parsed.get("jobs")
754
+ if isinstance(jobs, dict) and stage in jobs:
755
+ return True
756
+
757
+ stages = parsed.get("stages")
758
+ if isinstance(stages, dict) and stage in stages:
759
+ return True
760
+ if isinstance(stages, list) and stage in stages:
761
+ return True
762
+
763
+ return False
764
+
765
+ def _count_changed_lines(self, previous: str, current: str) -> int:
766
+ prev_lines = previous.splitlines()
767
+ curr_lines = current.splitlines()
768
+ changed = 0
769
+
770
+ max_len = max(len(prev_lines), len(curr_lines))
771
+ for idx in range(max_len):
772
+ left = prev_lines[idx] if idx < len(prev_lines) else ""
773
+ right = curr_lines[idx] if idx < len(curr_lines) else ""
774
+ if left != right:
775
+ changed += 1
776
+
777
+ return changed
778
+
779
+ def _normalize(self, value: str) -> str:
780
+ return re.sub(r"\s+", " ", value.strip().lower())
env/graders/__pycache__/__init__.cpython-312.pyc DELETED
Binary file (325 Bytes)
 
env/graders/__pycache__/deterministic.cpython-312.pyc DELETED
Binary file (9.45 kB)
 
env/graders/__pycache__/llm_judge.cpython-312.pyc DELETED
Binary file (5.58 kB)
 
env/rewards.py CHANGED
@@ -11,11 +11,14 @@ class RewardCalculator:
11
  """Composes progress, execution, quality, and anti-hacking penalties."""
12
 
13
  ACTION_PROGRESS_REWARDS = {
 
14
  "read_logs": 0.03,
15
  "analyze_error": 0.05,
16
- "propose_fix": 0.06,
17
- "edit_config": 0.05,
18
- "validate_fix": 0.06,
 
 
19
  }
20
 
21
  QUALITY_WEIGHTS = {
@@ -114,7 +117,7 @@ class RewardCalculator:
114
  deterministic_score = self.deterministic_grader.grade(current_config, expected_config, metadata)
115
 
116
  hidden_pass_rate = result.get("hidden_test_pass_rate")
117
- if hidden_pass_rate is None and action in {"validate_fix", "run_hidden_tests", "submit_fix"}:
118
  hidden_pass_rate = self.hidden_test_runner.evaluate_fix(
119
  fixed_config=current_config,
120
  expected_config=expected_config,
@@ -148,6 +151,10 @@ class RewardCalculator:
148
  edit_count = result.get("edit_count", state.get("edit_count", 0))
149
  step_count = int(state.get("step_count", 0) or 0)
150
  previous_config = result.get("previous_config") or state.get("previous_config") or ""
 
 
 
 
151
 
152
  penalty = self.anti_hacking_detector.total_penalty(
153
  current_config=current_config,
@@ -156,6 +163,8 @@ class RewardCalculator:
156
  changed_files_count=changed_files_count,
157
  changed_lines_count=changed_lines_count,
158
  step_count=step_count,
 
 
159
  )
160
 
161
  if result.get("hacking_attempt"):
 
11
  """Composes progress, execution, quality, and anti-hacking penalties."""
12
 
13
  ACTION_PROGRESS_REWARDS = {
14
+ "read_file": 0.02,
15
  "read_logs": 0.03,
16
  "analyze_error": 0.05,
17
+ "edit_config": 0.06,
18
+ "run_pipeline_stage": 0.07,
19
+ "run_tests": 0.08,
20
+ "validate_fix": 0.10,
21
+ "submit_solution": 0.12,
22
  }
23
 
24
  QUALITY_WEIGHTS = {
 
117
  deterministic_score = self.deterministic_grader.grade(current_config, expected_config, metadata)
118
 
119
  hidden_pass_rate = result.get("hidden_test_pass_rate")
120
+ if hidden_pass_rate is None and action in {"validate_fix", "submit_solution"}:
121
  hidden_pass_rate = self.hidden_test_runner.evaluate_fix(
122
  fixed_config=current_config,
123
  expected_config=expected_config,
 
151
  edit_count = result.get("edit_count", state.get("edit_count", 0))
152
  step_count = int(state.get("step_count", 0) or 0)
153
  previous_config = result.get("previous_config") or state.get("previous_config") or ""
154
+ consecutive_edit_actions = int(
155
+ result.get("consecutive_edit_actions", state.get("consecutive_edit_actions", 0)) or 0
156
+ )
157
+ failed_validations = int(result.get("failed_validations", state.get("failed_validations", 0)) or 0)
158
 
159
  penalty = self.anti_hacking_detector.total_penalty(
160
  current_config=current_config,
 
163
  changed_files_count=changed_files_count,
164
  changed_lines_count=changed_lines_count,
165
  step_count=step_count,
166
+ consecutive_edit_actions=consecutive_edit_actions,
167
+ failed_validations=failed_validations,
168
  )
169
 
170
  if result.get("hacking_attempt"):
env/tasks/__init__.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from env.tasks.easy import EASY_TASKS
4
+ from env.tasks.hard import HARD_TASKS
5
+ from env.tasks.medium import MEDIUM_TASKS
6
+ from env.tasks.task_types import CICDTask
7
+
8
+
9
+ def get_all_tasks() -> list[CICDTask]:
10
+ return [*EASY_TASKS, *MEDIUM_TASKS, *HARD_TASKS]
11
+
12
+
13
+ def get_tasks_by_difficulty(difficulty: str | None) -> list[CICDTask]:
14
+ if not difficulty:
15
+ return get_all_tasks()
16
+
17
+ normalized = difficulty.strip().lower()
18
+ return [task for task in get_all_tasks() if task.difficulty.lower() == normalized]
19
+
20
+
21
+ def get_task_by_id(task_id: str | None) -> CICDTask | None:
22
+ if not task_id:
23
+ return None
24
+
25
+ for task in get_all_tasks():
26
+ if task.task_id == task_id:
27
+ return task
28
+ return None
29
+
30
+
31
+ __all__ = [
32
+ "CICDTask",
33
+ "get_all_tasks",
34
+ "get_task_by_id",
35
+ "get_tasks_by_difficulty",
36
+ ]
env/tasks/easy.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from env.tasks.task_types import CICDTask
4
+
5
+
6
+ EASY_TASKS: list[CICDTask] = [
7
+ CICDTask(
8
+ task_id="easy-command-typo",
9
+ title="Fix test command typo",
10
+ description="A typo in the test command breaks the CI test stage.",
11
+ difficulty="easy",
12
+ failure_stage="test",
13
+ broken_config="""
14
+ name: CI
15
+ on: [push]
16
+ jobs:
17
+ build:
18
+ runs-on: ubuntu-latest
19
+ steps:
20
+ - uses: actions/checkout@v4
21
+ - run: npm ci
22
+ test:
23
+ runs-on: ubuntu-latest
24
+ needs: build
25
+ steps:
26
+ - uses: actions/checkout@v4
27
+ - run: npm tset
28
+ """.strip(),
29
+ expected_config="""
30
+ name: CI
31
+ on: [push]
32
+ jobs:
33
+ build:
34
+ runs-on: ubuntu-latest
35
+ steps:
36
+ - uses: actions/checkout@v4
37
+ - run: npm ci
38
+ test:
39
+ runs-on: ubuntu-latest
40
+ needs: build
41
+ steps:
42
+ - uses: actions/checkout@v4
43
+ - run: npm test
44
+ """.strip(),
45
+ logs="test stage failed: npm ERR! missing script: tset",
46
+ error_message="command not found: npm tset",
47
+ actual_bug="test step runs npm tset instead of npm test",
48
+ metadata={"broken_token": "npm tset", "fixed_token": "npm test"},
49
+ ),
50
+ CICDTask(
51
+ task_id="easy-missing-checkout",
52
+ title="Add missing checkout",
53
+ description="Build stage fails because repository checkout is missing.",
54
+ difficulty="easy",
55
+ failure_stage="build",
56
+ broken_config="""
57
+ name: CI
58
+ on: [push]
59
+ jobs:
60
+ build:
61
+ runs-on: ubuntu-latest
62
+ steps:
63
+ - run: npm ci
64
+ - run: npm run build
65
+ """.strip(),
66
+ expected_config="""
67
+ name: CI
68
+ on: [push]
69
+ jobs:
70
+ build:
71
+ runs-on: ubuntu-latest
72
+ steps:
73
+ - uses: actions/checkout@v4
74
+ - run: npm ci
75
+ - run: npm run build
76
+ """.strip(),
77
+ logs="build stage failed: package-lock.json not found in workspace",
78
+ error_message="missing checkout step before build commands",
79
+ actual_bug="repository checkout step was removed",
80
+ metadata={"broken_token": "", "fixed_token": "uses: actions/checkout@v4"},
81
+ ),
82
+ CICDTask(
83
+ task_id="easy-yaml-indentation",
84
+ title="Fix YAML indentation",
85
+ description="Pipeline config has malformed YAML indentation.",
86
+ difficulty="easy",
87
+ failure_stage="build",
88
+ broken_config="""
89
+ name: CI
90
+ on: [push]
91
+ jobs:
92
+ test:
93
+ runs-on: ubuntu-latest
94
+ steps:
95
+ - uses: actions/checkout@v4
96
+ - run: pytest
97
+ """.strip(),
98
+ expected_config="""
99
+ name: CI
100
+ on: [push]
101
+ jobs:
102
+ test:
103
+ runs-on: ubuntu-latest
104
+ steps:
105
+ - uses: actions/checkout@v4
106
+ - run: pytest
107
+ """.strip(),
108
+ logs="yaml parser error: while parsing a block mapping",
109
+ error_message="invalid YAML structure in workflow file",
110
+ actual_bug="test command list item is mis-indented",
111
+ metadata={},
112
+ ),
113
+ ]
env/tasks/hard.py ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from env.tasks.task_types import CICDTask
4
+
5
+
6
+ HARD_TASKS: list[CICDTask] = [
7
+ CICDTask(
8
+ task_id="hard-matrix-logic",
9
+ title="Fix matrix include-exclude logic",
10
+ description="Matrix includes unsupported versions and causes deterministic CI breakage.",
11
+ difficulty="hard",
12
+ failure_stage="test",
13
+ broken_config="""
14
+ name: CI
15
+ on: [push]
16
+ jobs:
17
+ test:
18
+ runs-on: ${{ matrix.os }}
19
+ strategy:
20
+ matrix:
21
+ os: [ubuntu-latest, windows-latest]
22
+ python-version: ["3.10", "3.11", "3.13"]
23
+ steps:
24
+ - uses: actions/checkout@v4
25
+ - uses: actions/setup-python@v5
26
+ with:
27
+ python-version: ${{ matrix.python-version }}
28
+ - run: pip install -r requirements.txt
29
+ - run: pytest -q
30
+ """.strip(),
31
+ expected_config="""
32
+ name: CI
33
+ on: [push]
34
+ jobs:
35
+ test:
36
+ runs-on: ${{ matrix.os }}
37
+ strategy:
38
+ matrix:
39
+ os: [ubuntu-latest, windows-latest]
40
+ python-version: ["3.10", "3.11", "3.13"]
41
+ exclude:
42
+ - os: windows-latest
43
+ python-version: "3.13"
44
+ steps:
45
+ - uses: actions/checkout@v4
46
+ - uses: actions/setup-python@v5
47
+ with:
48
+ python-version: ${{ matrix.python-version }}
49
+ - run: pip install -r requirements.txt
50
+ - run: pytest -q
51
+ """.strip(),
52
+ logs="test stage failed: wheel build unavailable for windows-latest + python 3.13",
53
+ error_message="matrix includes unsupported runtime combination",
54
+ actual_bug="matrix logic is missing an exclude for unstable runtime pair",
55
+ metadata={"broken_token": "python-version: [\"3.10\", \"3.11\", \"3.13\"]", "fixed_token": "exclude:"},
56
+ ),
57
+ CICDTask(
58
+ task_id="hard-conditional-deploy",
59
+ title="Repair deploy conditional",
60
+ description="Deploy job runs regardless of failed tests due to always() condition.",
61
+ difficulty="hard",
62
+ failure_stage="deploy",
63
+ broken_config="""
64
+ name: CI
65
+ on: [push]
66
+ jobs:
67
+ build:
68
+ runs-on: ubuntu-latest
69
+ steps:
70
+ - uses: actions/checkout@v4
71
+ - run: npm ci
72
+ - run: npm run build
73
+ test:
74
+ runs-on: ubuntu-latest
75
+ needs: build
76
+ steps:
77
+ - uses: actions/checkout@v4
78
+ - run: npm test
79
+ deploy:
80
+ runs-on: ubuntu-latest
81
+ needs: test
82
+ if: always()
83
+ steps:
84
+ - run: echo deploying
85
+ """.strip(),
86
+ expected_config="""
87
+ name: CI
88
+ on: [push]
89
+ jobs:
90
+ build:
91
+ runs-on: ubuntu-latest
92
+ steps:
93
+ - uses: actions/checkout@v4
94
+ - run: npm ci
95
+ - run: npm run build
96
+ test:
97
+ runs-on: ubuntu-latest
98
+ needs: build
99
+ steps:
100
+ - uses: actions/checkout@v4
101
+ - run: npm test
102
+ deploy:
103
+ runs-on: ubuntu-latest
104
+ needs: test
105
+ if: success() && github.ref == 'refs/heads/main'
106
+ steps:
107
+ - run: echo deploying
108
+ """.strip(),
109
+ logs="deploy stage triggered despite failing tests on non-main branch",
110
+ error_message="unsafe deploy condition bypasses quality gates",
111
+ actual_bug="deploy condition uses always() instead of guarded success check",
112
+ metadata={"broken_token": "if: always()", "fixed_token": "if: success() && github.ref == 'refs/heads/main'"},
113
+ ),
114
+ CICDTask(
115
+ task_id="hard-needs-order",
116
+ title="Fix job dependency ordering",
117
+ description="Deploy depends only on build and can run before tests complete.",
118
+ difficulty="hard",
119
+ failure_stage="deploy",
120
+ broken_config="""
121
+ name: CI
122
+ on: [push]
123
+ jobs:
124
+ build:
125
+ runs-on: ubuntu-latest
126
+ steps:
127
+ - uses: actions/checkout@v4
128
+ - run: npm ci
129
+ test:
130
+ runs-on: ubuntu-latest
131
+ needs: build
132
+ steps:
133
+ - uses: actions/checkout@v4
134
+ - run: npm test
135
+ deploy:
136
+ runs-on: ubuntu-latest
137
+ needs: build
138
+ steps:
139
+ - run: echo deploying package
140
+ """.strip(),
141
+ expected_config="""
142
+ name: CI
143
+ on: [push]
144
+ jobs:
145
+ build:
146
+ runs-on: ubuntu-latest
147
+ steps:
148
+ - uses: actions/checkout@v4
149
+ - run: npm ci
150
+ test:
151
+ runs-on: ubuntu-latest
152
+ needs: build
153
+ steps:
154
+ - uses: actions/checkout@v4
155
+ - run: npm test
156
+ deploy:
157
+ runs-on: ubuntu-latest
158
+ needs: [build, test]
159
+ steps:
160
+ - run: echo deploying package
161
+ """.strip(),
162
+ logs="deploy stage started before tests finished, causing regression release",
163
+ error_message="deploy dependency graph skips mandatory test gate",
164
+ actual_bug="deploy job does not depend on test job",
165
+ metadata={"broken_token": "needs: build", "fixed_token": "needs: [build, test]"},
166
+ ),
167
+ ]
env/tasks/medium.py ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from env.tasks.task_types import CICDTask
4
+
5
+
6
+ MEDIUM_TASKS: list[CICDTask] = [
7
+ CICDTask(
8
+ task_id="medium-python-version",
9
+ title="Align Python version",
10
+ description="Tests require Python 3.11 but workflow pins an older version.",
11
+ difficulty="medium",
12
+ failure_stage="build",
13
+ broken_config="""
14
+ name: CI
15
+ on: [push]
16
+ jobs:
17
+ test:
18
+ runs-on: ubuntu-latest
19
+ steps:
20
+ - uses: actions/checkout@v4
21
+ - uses: actions/setup-python@v5
22
+ with:
23
+ python-version: "3.8"
24
+ - run: pip install -r requirements.txt
25
+ - run: pytest -q
26
+ """.strip(),
27
+ expected_config="""
28
+ name: CI
29
+ on: [push]
30
+ jobs:
31
+ test:
32
+ runs-on: ubuntu-latest
33
+ steps:
34
+ - uses: actions/checkout@v4
35
+ - uses: actions/setup-python@v5
36
+ with:
37
+ python-version: "3.11"
38
+ - run: pip install -r requirements.txt
39
+ - run: pytest -q
40
+ """.strip(),
41
+ logs="build failed: package requires python>=3.11",
42
+ error_message="python interpreter version mismatch",
43
+ actual_bug="workflow pins python-version 3.8 while project requires 3.11",
44
+ metadata={"broken_token": 'python-version: "3.8"', "fixed_token": 'python-version: "3.11"'},
45
+ ),
46
+ CICDTask(
47
+ task_id="medium-cache-key",
48
+ title="Fix cache invalidation key",
49
+ description="Dependency cache key ignores lockfile hash and restores stale dependencies.",
50
+ difficulty="medium",
51
+ failure_stage="test",
52
+ broken_config="""
53
+ name: CI
54
+ on: [push]
55
+ jobs:
56
+ test:
57
+ runs-on: ubuntu-latest
58
+ steps:
59
+ - uses: actions/checkout@v4
60
+ - uses: actions/setup-node@v4
61
+ with:
62
+ node-version: 20
63
+ - uses: actions/cache@v4
64
+ with:
65
+ path: ~/.npm
66
+ key: node-modules-${{ runner.os }}
67
+ - run: npm ci
68
+ - run: npm test
69
+ """.strip(),
70
+ expected_config="""
71
+ name: CI
72
+ on: [push]
73
+ jobs:
74
+ test:
75
+ runs-on: ubuntu-latest
76
+ steps:
77
+ - uses: actions/checkout@v4
78
+ - uses: actions/setup-node@v4
79
+ with:
80
+ node-version: 20
81
+ - uses: actions/cache@v4
82
+ with:
83
+ path: ~/.npm
84
+ key: node-modules-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
85
+ - run: npm ci
86
+ - run: npm test
87
+ """.strip(),
88
+ logs="test failed: stale cache restored old dependency tree",
89
+ error_message="cache key misses lockfile fingerprint",
90
+ actual_bug="cache key is too broad and never invalidates on dependency changes",
91
+ metadata={"broken_token": "key: node-modules-${{ runner.os }}", "fixed_token": "hashFiles('**/package-lock.json')"},
92
+ ),
93
+ CICDTask(
94
+ task_id="medium-artifact-permissions",
95
+ title="Repair artifact permissions",
96
+ description="Artifact upload fails due to insufficient token permissions.",
97
+ difficulty="medium",
98
+ failure_stage="deploy",
99
+ broken_config="""
100
+ name: CI
101
+ on: [push]
102
+ permissions:
103
+ contents: read
104
+ jobs:
105
+ build:
106
+ runs-on: ubuntu-latest
107
+ steps:
108
+ - uses: actions/checkout@v4
109
+ - run: npm ci
110
+ - run: npm run build
111
+ - uses: actions/upload-artifact@v4
112
+ with:
113
+ name: web-build
114
+ path: dist/
115
+ """.strip(),
116
+ expected_config="""
117
+ name: CI
118
+ on: [push]
119
+ permissions:
120
+ contents: read
121
+ actions: write
122
+ jobs:
123
+ build:
124
+ runs-on: ubuntu-latest
125
+ steps:
126
+ - uses: actions/checkout@v4
127
+ - run: npm ci
128
+ - run: npm run build
129
+ - uses: actions/upload-artifact@v4
130
+ with:
131
+ name: web-build
132
+ path: dist/
133
+ """.strip(),
134
+ logs="deploy stage failed: Resource not accessible by integration",
135
+ error_message="insufficient permissions for upload-artifact",
136
+ actual_bug="actions:write permission missing from workflow permissions",
137
+ metadata={"broken_token": "permissions:\n contents: read", "fixed_token": "actions: write"},
138
+ ),
139
+ ]
env/tasks/task_types.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class CICDTask:
9
+ """Represents a single CI/CD debugging scenario."""
10
+
11
+ task_id: str
12
+ title: str
13
+ description: str
14
+ difficulty: str
15
+ failure_stage: str
16
+ broken_config: str
17
+ expected_config: str
18
+ logs: str
19
+ error_message: str
20
+ actual_bug: str
21
+ metadata: dict[str, Any] = field(default_factory=dict)
inference.py CHANGED
@@ -1,218 +1,31 @@
1
- # pyright: reportMissingImports=false
2
  from __future__ import annotations
3
 
4
  import argparse
5
  import asyncio
6
- import json
7
  import os
8
- import re
9
- from dataclasses import dataclass
10
- from itertools import zip_longest
11
  from typing import Any
12
 
13
  from openai import OpenAI
14
- import yaml
15
 
16
- from env.rewards import RewardCalculator
17
  from inference.metrics import EpisodeMetrics
18
  from inference.model_wrapper import ModelWrapper, score_action_candidate
19
- from inference.prompts import JUDGE_SYSTEM_PROMPT, heuristic_action
20
  from inference.visualize import save_metrics_json, save_reward_curve, save_success_rate_history
21
 
22
- try:
23
- from my_env_v4 import MyEnvV4Action, MyEnvV4Env # type: ignore[import-not-found]
24
-
25
- EXTERNAL_ENV_AVAILABLE = True
26
- except Exception:
27
- MyEnvV4Action = None
28
- MyEnvV4Env = None
29
- EXTERNAL_ENV_AVAILABLE = False
30
-
31
 
32
  API_BASE_URL = os.getenv("API_BASE_URL", "https://router.huggingface.co/v1")
33
  MODEL_NAME = os.getenv("MODEL_NAME", "Qwen/Qwen2.5-72B-Instruct")
34
  HF_TOKEN = os.getenv("HF_TOKEN")
35
  LOCAL_IMAGE_NAME = os.getenv("LOCAL_IMAGE_NAME")
36
-
37
- TASK_NAME = os.getenv("MY_ENV_V4_TASK", "cicd-debugger-task")
38
- BENCHMARK = os.getenv("MY_ENV_V4_BENCHMARK", "cicd_debugger_env")
39
 
40
  MAX_STEPS_DEFAULT = int(os.getenv("MAX_STEPS", "8"))
41
  TEMPERATURE = float(os.getenv("TEMPERATURE", "0.2"))
42
  MAX_TOKENS = int(os.getenv("MAX_TOKENS", "120"))
43
  OFFLINE_INFERENCE = os.getenv("OFFLINE_INFERENCE", "0") == "1"
44
-
45
-
46
- DEFAULT_ORIGINAL_CONFIG = """
47
- name: CI
48
- on: [push]
49
- jobs:
50
- test:
51
- runs-on: ubuntu-latest
52
- steps:
53
- - uses: actions/checkout@v4
54
- - run: npm ci
55
- - run: npm tset
56
- """.strip()
57
-
58
- DEFAULT_EXPECTED_CONFIG = """
59
- name: CI
60
- on: [push]
61
- jobs:
62
- test:
63
- runs-on: ubuntu-latest
64
- steps:
65
- - uses: actions/checkout@v4
66
- - run: npm ci
67
- - run: npm test
68
- """.strip()
69
-
70
- DEFAULT_ERROR_MESSAGE = "command not found"
71
-
72
-
73
- @dataclass
74
- class LocalObservation:
75
- config: str
76
- error_message: str
77
- logs: str
78
- last_action_error: str | None = None
79
-
80
-
81
- @dataclass
82
- class LocalStepResult:
83
- observation: LocalObservation
84
- reward: float
85
- done: bool
86
- last_action_error: str | None = None
87
-
88
-
89
- @dataclass
90
- class LocalAction:
91
- message: str
92
-
93
-
94
- class LocalCICDDebuggerEnv:
95
- def __init__(self, original_config: str, expected_config: str, error_message: str):
96
- self.original_config = original_config
97
- self.expected_config = expected_config
98
- self.error_message = error_message
99
- self.current_config = original_config
100
-
101
- async def reset(self) -> LocalStepResult:
102
- self.current_config = self.original_config
103
- obs = LocalObservation(
104
- config=self.current_config,
105
- error_message=self.error_message,
106
- logs="CI failed in test step: npm tset is not a valid command.",
107
- last_action_error=None,
108
- )
109
- return LocalStepResult(observation=obs, reward=0.0, done=False, last_action_error=None)
110
-
111
- async def step(self, action: LocalAction) -> LocalStepResult:
112
- message = str(action.message or "").strip()
113
- lower_message = message.lower()
114
- previous = self.current_config
115
- step_error: str | None = None
116
- logs = "No effective change applied."
117
-
118
- if _is_hacking_action(message):
119
- step_error = "disallowed_hacking_pattern"
120
- logs = "Rejected unsafe action pattern."
121
- elif "npm tset" in lower_message and "npm test" in lower_message and "npm tset" in previous:
122
- self.current_config = previous.replace("npm tset", "npm test")
123
- logs = "Patched CI command typo from npm tset to npm test."
124
- elif "replace" in lower_message and "npm test" in lower_message and "npm tset" in previous:
125
- self.current_config = previous.replace("npm tset", "npm test")
126
- logs = "Applied replace operation for broken test command."
127
- elif "npm test" in lower_message and "npm tset" in previous:
128
- self.current_config = previous.replace("npm tset", "npm test")
129
- logs = "Applied inferred command fix."
130
-
131
- done = "npm tset" not in self.current_config.lower() and "npm test" in self.current_config.lower()
132
- reward = 1.0 if done else 0.0
133
- err_msg = "" if done else self.error_message
134
-
135
- obs = LocalObservation(
136
- config=self.current_config,
137
- error_message=err_msg,
138
- logs=logs,
139
- last_action_error=step_error,
140
- )
141
- return LocalStepResult(observation=obs, reward=reward, done=done, last_action_error=step_error)
142
-
143
- async def close(self) -> None:
144
- return None
145
-
146
-
147
- class OpenAIJudgeAdapter:
148
- def __init__(self, client: OpenAI, model_name: str):
149
- self.client = client
150
- self.model_name = model_name
151
-
152
- def evaluate_fix(self, original: str, fixed: str, error: str) -> dict[str, float]:
153
- prompt = (
154
- "Evaluate CI config fix quality. Return JSON only with keys correctness, minimalism, quality in [0,1].\n\n"
155
- f"Original:\n{original}\n\n"
156
- f"Fixed:\n{fixed}\n\n"
157
- f"Error:\n{error}\n"
158
- )
159
- default = {"correctness": 0.0, "minimalism": 0.0, "quality": 0.0}
160
-
161
- try:
162
- completion = self.client.chat.completions.create(
163
- model=self.model_name,
164
- messages=[
165
- {"role": "system", "content": JUDGE_SYSTEM_PROMPT},
166
- {"role": "user", "content": prompt},
167
- ],
168
- temperature=0.0,
169
- max_tokens=120,
170
- stream=False,
171
- )
172
- content = (completion.choices[0].message.content or "").strip()
173
- except Exception:
174
- return default
175
-
176
- parsed = self._parse_scores(content)
177
- return parsed if parsed else default
178
-
179
- def _parse_scores(self, content: str) -> dict[str, float] | None:
180
- decoder = json.JSONDecoder()
181
- for idx, char in enumerate(content):
182
- if char != "{":
183
- continue
184
- try:
185
- obj, _ = decoder.raw_decode(content[idx:])
186
- except json.JSONDecodeError:
187
- continue
188
- if isinstance(obj, dict):
189
- return {
190
- "correctness": self._clamp(obj.get("correctness", 0.0)),
191
- "minimalism": self._clamp(obj.get("minimalism", 0.0)),
192
- "quality": self._clamp(obj.get("quality", 0.0)),
193
- }
194
-
195
- fallback = {
196
- "correctness": self._extract_regex(content, "correctness"),
197
- "minimalism": self._extract_regex(content, "minimalism"),
198
- "quality": self._extract_regex(content, "quality"),
199
- }
200
- if any(value > 0 for value in fallback.values()):
201
- return fallback
202
- return None
203
-
204
- def _extract_regex(self, content: str, key: str) -> float:
205
- match = re.search(rf"{key}\s*[:=\-]\s*([0-9]*\.?[0-9]+)", content, flags=re.IGNORECASE)
206
- if not match:
207
- return 0.0
208
- return self._clamp(match.group(1))
209
-
210
- def _clamp(self, value: Any) -> float:
211
- try:
212
- parsed = float(value)
213
- except (TypeError, ValueError):
214
- parsed = 0.0
215
- return max(0.0, min(1.0, parsed))
216
 
217
 
218
  def log_start(task: str, env_name: str, model: str) -> None:
@@ -226,83 +39,15 @@ def log_step(step: int, action: str, reward: float, done: bool, error: str | Non
226
  print(f"[STEP] step={step} action={action_val} reward={reward:.2f} done={done_val} error={error_val}", flush=True)
227
 
228
 
229
- def log_end(success: bool, steps: int, rewards: list[float]) -> None:
230
  rewards_str = ",".join(f"{value:.2f}" for value in rewards)
231
- print(f"[END] success={str(success).lower()} steps={steps} rewards={rewards_str}", flush=True)
232
 
233
 
234
  def _single_line(value: Any) -> str:
235
  return " ".join(str(value).replace("\n", " ").replace("\r", " ").split())
236
 
237
 
238
- def _safe_float(value: Any) -> float:
239
- try:
240
- return float(value or 0.0)
241
- except (TypeError, ValueError):
242
- return 0.0
243
-
244
-
245
- def _extract_observation(result: Any) -> Any:
246
- return getattr(result, "observation", result)
247
-
248
-
249
- def _extract_done(result: Any) -> bool:
250
- return bool(getattr(result, "done", False))
251
-
252
-
253
- def _extract_reward(result: Any) -> float:
254
- return _safe_float(getattr(result, "reward", 0.0))
255
-
256
-
257
- def _extract_error(result: Any, observation: Any) -> str | None:
258
- result_error = getattr(result, "last_action_error", None)
259
- if result_error:
260
- return str(result_error)
261
-
262
- if isinstance(observation, dict):
263
- obs_err = observation.get("last_action_error")
264
- if obs_err:
265
- return str(obs_err)
266
- else:
267
- obs_err = getattr(observation, "last_action_error", None)
268
- if obs_err:
269
- return str(obs_err)
270
-
271
- return None
272
-
273
-
274
- def _extract_observation_text(observation: Any) -> str:
275
- if isinstance(observation, dict):
276
- for key in ("config", "current_config", "echoed_message"):
277
- if key in observation and observation.get(key) is not None:
278
- return str(observation.get(key))
279
- return str(observation)
280
-
281
- for attr in ("config", "current_config", "echoed_message"):
282
- if hasattr(observation, attr):
283
- value = getattr(observation, attr)
284
- if value is not None:
285
- return str(value)
286
-
287
- return str(observation)
288
-
289
-
290
- def _extract_observation_error_message(observation: Any) -> str:
291
- if isinstance(observation, dict):
292
- return str(observation.get("error_message", "") or "")
293
- return str(getattr(observation, "error_message", "") or "")
294
-
295
-
296
- def _count_changed_lines(previous_text: str, current_text: str) -> int:
297
- previous_lines = (previous_text or "").splitlines()
298
- current_lines = (current_text or "").splitlines()
299
- changed = 0
300
- for left, right in zip_longest(previous_lines, current_lines, fillvalue=""):
301
- if left != right:
302
- changed += 1
303
- return changed
304
-
305
-
306
  def _is_hacking_action(action_text: str) -> bool:
307
  value = (action_text or "").lower()
308
  patterns = (
@@ -312,21 +57,54 @@ def _is_hacking_action(action_text: str) -> bool:
312
  "echo 'tests passed'",
313
  "exit 0",
314
  "force success",
 
315
  )
316
  return any(token in value for token in patterns)
317
 
318
 
319
- def _classify_action(action_text: str) -> str:
320
- text = (action_text or "").lower()
321
- if "read" in text and "log" in text:
322
- return "read_logs"
323
- if "analy" in text:
324
- return "analyze_error"
325
- if "propose" in text:
326
- return "propose_fix"
327
- if any(token in text for token in ("validate", "run test", "pipeline run", "verify")):
328
- return "validate_fix"
329
- return "edit_config"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
330
 
331
 
332
  def _select_action(
@@ -335,21 +113,25 @@ def _select_action(
335
  config_text: str,
336
  error_message: str,
337
  history: list[str],
 
338
  policy_mode: str,
339
  trajectories: int,
340
  ) -> str:
341
  mode = (policy_mode or "imp").lower()
 
342
 
343
  if mode == "sft":
344
- return heuristic_action(config_text, error_message)
345
 
346
  if mode == "direct":
347
- return model_wrapper.generate_action(
348
  step=step,
349
  config_text=config_text,
350
  error_message=error_message,
351
  history=history,
 
352
  )
 
353
 
354
  candidates = model_wrapper.generate_candidates(
355
  step=step,
@@ -357,194 +139,96 @@ def _select_action(
357
  error_message=error_message,
358
  history=history,
359
  count=max(1, int(trajectories)),
 
360
  )
361
 
362
  if not candidates:
363
- return heuristic_action(config_text, error_message)
364
 
365
- observation = f"{config_text}\n{error_message}"
366
- best = max(candidates, key=lambda item: score_action_candidate(observation, item, _is_hacking_action))
367
- return best
368
-
369
-
370
- def _build_action(action_class: Any, message: str) -> Any:
371
- try:
372
- return action_class(message=message)
373
- except TypeError:
374
- return action_class(message)
375
-
376
-
377
- async def _load_environment(
378
- original_config: str,
379
- expected_config: str,
380
- error_message: str,
381
- force_local_env: bool,
382
- ) -> tuple[Any, Any]:
383
- if not force_local_env and EXTERNAL_ENV_AVAILABLE and LOCAL_IMAGE_NAME:
384
- try:
385
- env = await MyEnvV4Env.from_docker_image(LOCAL_IMAGE_NAME)
386
- return env, MyEnvV4Action
387
- except Exception:
388
- pass
389
-
390
- env = LocalCICDDebuggerEnv(
391
- original_config=original_config,
392
- expected_config=expected_config,
393
- error_message=error_message,
394
- )
395
- return env, LocalAction
396
-
397
-
398
- def _load_text(raw_value: str | None, file_path: str | None, fallback: str) -> str:
399
- if raw_value:
400
- return raw_value
401
- if file_path:
402
- with open(file_path, "r", encoding="utf-8") as handle:
403
- return handle.read().strip()
404
- return fallback
405
 
406
 
407
  def parse_args() -> argparse.Namespace:
408
- parser = argparse.ArgumentParser(description="Run OpenEnv-style CI/CD pipeline debugging inference loop")
409
  parser.add_argument("--max-steps", type=int, default=MAX_STEPS_DEFAULT)
410
- parser.add_argument("--task", default=TASK_NAME)
411
- parser.add_argument("--benchmark", default=BENCHMARK)
 
412
  parser.add_argument("--offline", action="store_true", default=OFFLINE_INFERENCE)
 
413
  parser.add_argument("--policy-mode", choices=["sft", "imp", "direct"], default="imp")
414
  parser.add_argument("--trajectories", type=int, default=3)
415
- parser.add_argument("--force-local-env", action="store_true", default=False)
416
-
417
- parser.add_argument("--original-config", default=None)
418
- parser.add_argument("--original-config-file", default=None)
419
- parser.add_argument("--expected-config", default=None)
420
- parser.add_argument("--expected-config-file", default=None)
421
- parser.add_argument("--error-message", default=DEFAULT_ERROR_MESSAGE)
422
-
423
  return parser.parse_args()
424
 
425
 
426
  async def run_episode(args: argparse.Namespace) -> int:
427
- original_config = _load_text(args.original_config, args.original_config_file, DEFAULT_ORIGINAL_CONFIG)
428
- expected_config = _load_text(args.expected_config, args.expected_config_file, DEFAULT_EXPECTED_CONFIG)
429
- error_message = str(args.error_message or DEFAULT_ERROR_MESSAGE)
430
-
431
- env = None
432
  history: list[str] = []
433
  steps_taken = 0
434
  success = False
 
435
  metrics = EpisodeMetrics()
436
 
 
 
437
  offline_mode = bool(args.offline or not HF_TOKEN)
438
  client: OpenAI | None = None
439
  if not offline_mode:
440
- client = OpenAI(base_url=API_BASE_URL, api_key=HF_TOKEN or "")
 
 
 
 
 
 
 
 
 
 
 
 
441
 
442
  log_start(task=str(args.task), env_name=str(args.benchmark), model=MODEL_NAME)
443
 
444
  try:
445
- env, action_class = await _load_environment(
446
- original_config=original_config,
447
- expected_config=expected_config,
448
- error_message=error_message,
449
- force_local_env=bool(args.force_local_env),
450
- )
451
-
452
- judge_adapter = OpenAIJudgeAdapter(client, MODEL_NAME) if client is not None else None
453
- reward_calculator = RewardCalculator(llm_judge=judge_adapter)
454
- model_wrapper = ModelWrapper(
455
- client=client,
456
- model_name=MODEL_NAME,
457
- temperature=TEMPERATURE,
458
- max_tokens=MAX_TOKENS,
459
- offline=offline_mode,
460
- )
461
-
462
- reset_result = await env.reset()
463
- observation = _extract_observation(reset_result)
464
- previous_config = original_config
465
- current_error_message = error_message
466
 
467
  for step in range(1, max(1, int(args.max_steps)) + 1):
468
- config_text = _extract_observation_text(observation) or previous_config
469
- obs_error = _extract_observation_error_message(observation)
470
- if obs_error:
471
- current_error_message = obs_error
472
 
473
  action_text = _select_action(
474
  model_wrapper=model_wrapper,
475
  step=step,
476
  config_text=config_text,
477
- error_message=current_error_message,
478
  history=history,
 
479
  policy_mode=str(args.policy_mode),
480
  trajectories=max(1, int(args.trajectories)),
481
  )
482
- action_obj = _build_action(action_class, action_text)
483
-
484
- step_result = await env.step(action_obj)
485
- observation = _extract_observation(step_result)
486
-
487
- env_reward = _extract_reward(step_result)
488
- done = _extract_done(step_result)
489
- step_error = _extract_error(step_result, observation)
490
- current_config = _extract_observation_text(observation) or config_text
491
- obs_error = _extract_observation_error_message(observation)
492
- if obs_error:
493
- current_error_message = obs_error
494
-
495
- action_type = _classify_action(action_text)
496
- hacking_attempt = _is_hacking_action(action_text)
497
-
498
- result_for_reward = {
499
- "previous_config": previous_config,
500
- "current_config": current_config,
501
- "fixed_config": current_config,
502
- "expected_config": expected_config,
503
- "error": current_error_message,
504
- "logs_analyzed": "log" in action_text.lower() or action_type == "read_logs",
505
- "error_diagnosed": action_type in {"analyze_error", "propose_fix", "edit_config", "validate_fix"},
506
- "fix_proposed": action_type in {"propose_fix", "edit_config"},
507
- "pipeline_run": action_type == "validate_fix" and step_error is None,
508
- "tests_passed": done,
509
- "command_succeeded": step_error is None,
510
- "changed_files_count": 1 if previous_config != current_config else 0,
511
- "changed_lines_count": _count_changed_lines(previous_config, current_config),
512
- "hacking_attempt": hacking_attempt,
513
- }
514
-
515
- calculated_reward = reward_calculator.calculate_step_reward(
516
- state={
517
- "step_count": step,
518
- "previous_config": previous_config,
519
- "expected_config": expected_config,
520
- "original_config": original_config,
521
- "error": current_error_message,
522
- },
523
- action=action_type,
524
- result=result_for_reward,
525
- original_config=original_config,
526
- fixed_config=current_config,
527
- error_message=current_error_message,
528
- expected_config=expected_config,
529
- metadata={"broken_token": "npm tset", "fixed_token": "npm test"},
530
- )
531
 
532
- combined_reward = round(max(0.0, min(1.0, float(calculated_reward) + float(env_reward))), 4)
533
- metrics.add_step(action=action_text, reward=combined_reward, error=step_error, done=done)
534
- steps_taken = step
535
 
536
- log_step(step=step, action=action_text, reward=combined_reward, done=done, error=step_error)
 
537
 
538
- history.append(f"step={step} action={_single_line(action_text)} reward={combined_reward:.2f}")
539
- previous_config = current_config
540
 
541
  if done:
542
- success = step_error is None and not hacking_attempt
543
  break
544
 
545
- except Exception:
546
  success = False
 
 
547
  finally:
 
 
 
548
  try:
549
  save_reward_curve(metrics.rewards)
550
  save_metrics_json(metrics.summary())
@@ -552,13 +236,12 @@ async def run_episode(args: argparse.Namespace) -> int:
552
  except Exception:
553
  pass
554
 
555
- if env is not None:
556
- try:
557
- await env.close()
558
- except Exception:
559
- pass
560
 
561
- log_end(success=success, steps=steps_taken, rewards=metrics.rewards)
562
 
563
  return 0
564
 
 
 
1
  from __future__ import annotations
2
 
3
  import argparse
4
  import asyncio
 
5
  import os
 
 
 
6
  from typing import Any
7
 
8
  from openai import OpenAI
 
9
 
10
+ from env.environment import CICDDebuggerEnvironment, REQUIRED_TOOLS
11
  from inference.metrics import EpisodeMetrics
12
  from inference.model_wrapper import ModelWrapper, score_action_candidate
13
+ from inference.prompts import heuristic_action
14
  from inference.visualize import save_metrics_json, save_reward_curve, save_success_rate_history
15
 
 
 
 
 
 
 
 
 
 
16
 
17
  API_BASE_URL = os.getenv("API_BASE_URL", "https://router.huggingface.co/v1")
18
  MODEL_NAME = os.getenv("MODEL_NAME", "Qwen/Qwen2.5-72B-Instruct")
19
  HF_TOKEN = os.getenv("HF_TOKEN")
20
  LOCAL_IMAGE_NAME = os.getenv("LOCAL_IMAGE_NAME")
21
+ DEFAULT_TASK_ID = os.getenv("MY_ENV_V4_TASK", "easy-command-typo")
22
+ DEFAULT_BENCHMARK = os.getenv("MY_ENV_V4_BENCHMARK", "cicd_debugger_env")
 
23
 
24
  MAX_STEPS_DEFAULT = int(os.getenv("MAX_STEPS", "8"))
25
  TEMPERATURE = float(os.getenv("TEMPERATURE", "0.2"))
26
  MAX_TOKENS = int(os.getenv("MAX_TOKENS", "120"))
27
  OFFLINE_INFERENCE = os.getenv("OFFLINE_INFERENCE", "0") == "1"
28
+ SUCCESS_SCORE_THRESHOLD = float(os.getenv("SUCCESS_SCORE_THRESHOLD", "0.1"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
 
30
 
31
  def log_start(task: str, env_name: str, model: str) -> None:
 
39
  print(f"[STEP] step={step} action={action_val} reward={reward:.2f} done={done_val} error={error_val}", flush=True)
40
 
41
 
42
+ def log_end(success: bool, steps: int, score: float, rewards: list[float]) -> None:
43
  rewards_str = ",".join(f"{value:.2f}" for value in rewards)
44
+ print(f"[END] success={str(success).lower()} steps={steps} score={score:.3f} rewards={rewards_str}", flush=True)
45
 
46
 
47
  def _single_line(value: Any) -> str:
48
  return " ".join(str(value).replace("\n", " ").replace("\r", " ").split())
49
 
50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  def _is_hacking_action(action_text: str) -> bool:
52
  value = (action_text or "").lower()
53
  patterns = (
 
57
  "echo 'tests passed'",
58
  "exit 0",
59
  "force success",
60
+ "status: success",
61
  )
62
  return any(token in value for token in patterns)
63
 
64
 
65
+ def _extract_error(info: dict[str, Any] | None) -> str | None:
66
+ if not info:
67
+ return None
68
+ error = info.get("error")
69
+ return str(error) if error else None
70
+
71
+
72
+ def _extract_observation_fields(observation: dict[str, Any]) -> tuple[str, str, list[str]]:
73
+ config_text = str(observation.get("config") or "")
74
+ error_message = str(observation.get("error_message") or "")
75
+ tools = [str(item) for item in (observation.get("available_tools") or REQUIRED_TOOLS)]
76
+ return config_text, error_message, tools
77
+
78
+
79
+ def _tool_from_action(action_text: str) -> str:
80
+ return str(action_text or "").split(":", 1)[0].strip().lower()
81
+
82
+
83
+ def _is_action_allowed(action_text: str, available_tools: list[str]) -> bool:
84
+ return _tool_from_action(action_text) in {tool.lower() for tool in available_tools}
85
+
86
+
87
+ def _normalize_action(action_text: str, available_tools: list[str], fallback: str) -> str:
88
+ action = str(action_text or "").strip()
89
+ if not action:
90
+ return fallback
91
+
92
+ aliases = {
93
+ "run_stage": "run_pipeline_stage",
94
+ "validate": "validate_fix",
95
+ "submit": "submit_solution",
96
+ "submit_fix": "submit_solution",
97
+ }
98
+ tool = _tool_from_action(action)
99
+ normalized_tool = aliases.get(tool, tool)
100
+ if normalized_tool != tool:
101
+ suffix = action.split(":", 1)[1].strip() if ":" in action else ""
102
+ action = f"{normalized_tool}: {suffix}" if suffix else normalized_tool
103
+
104
+ if _is_action_allowed(action, available_tools):
105
+ return action
106
+
107
+ return fallback
108
 
109
 
110
  def _select_action(
 
113
  config_text: str,
114
  error_message: str,
115
  history: list[str],
116
+ available_actions: list[str],
117
  policy_mode: str,
118
  trajectories: int,
119
  ) -> str:
120
  mode = (policy_mode or "imp").lower()
121
+ fallback = heuristic_action(config_text, error_message, available_actions, history)
122
 
123
  if mode == "sft":
124
+ return _normalize_action(fallback, available_actions, fallback)
125
 
126
  if mode == "direct":
127
+ action = model_wrapper.generate_action(
128
  step=step,
129
  config_text=config_text,
130
  error_message=error_message,
131
  history=history,
132
+ available_actions=available_actions,
133
  )
134
+ return _normalize_action(action, available_actions, fallback)
135
 
136
  candidates = model_wrapper.generate_candidates(
137
  step=step,
 
139
  error_message=error_message,
140
  history=history,
141
  count=max(1, int(trajectories)),
142
+ available_actions=available_actions,
143
  )
144
 
145
  if not candidates:
146
+ return _normalize_action(fallback, available_actions, fallback)
147
 
148
+ observation_text = f"{config_text}\n{error_message}"
149
+ best = max(candidates, key=lambda item: score_action_candidate(observation_text, item, _is_hacking_action))
150
+ return _normalize_action(best, available_actions, fallback)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
 
152
 
153
  def parse_args() -> argparse.Namespace:
154
+ parser = argparse.ArgumentParser(description="Run CI/CD debugger inference loop")
155
  parser.add_argument("--max-steps", type=int, default=MAX_STEPS_DEFAULT)
156
+ parser.add_argument("--task", default=DEFAULT_TASK_ID)
157
+ parser.add_argument("--benchmark", default=DEFAULT_BENCHMARK)
158
+ parser.add_argument("--difficulty", choices=["easy", "medium", "hard"], default=None)
159
  parser.add_argument("--offline", action="store_true", default=OFFLINE_INFERENCE)
160
+ parser.add_argument("--force-local-env", action="store_true", default=False)
161
  parser.add_argument("--policy-mode", choices=["sft", "imp", "direct"], default="imp")
162
  parser.add_argument("--trajectories", type=int, default=3)
 
 
 
 
 
 
 
 
163
  return parser.parse_args()
164
 
165
 
166
  async def run_episode(args: argparse.Namespace) -> int:
 
 
 
 
 
167
  history: list[str] = []
168
  steps_taken = 0
169
  success = False
170
+ episode_completed_cleanly = False
171
  metrics = EpisodeMetrics()
172
 
173
+ env = CICDDebuggerEnvironment(max_steps=max(1, int(args.max_steps)))
174
+
175
  offline_mode = bool(args.offline or not HF_TOKEN)
176
  client: OpenAI | None = None
177
  if not offline_mode:
178
+ try:
179
+ client = OpenAI(base_url=API_BASE_URL, api_key=HF_TOKEN)
180
+ except Exception:
181
+ client = None
182
+ offline_mode = True
183
+
184
+ model_wrapper = ModelWrapper(
185
+ client=client,
186
+ model_name=MODEL_NAME,
187
+ temperature=TEMPERATURE,
188
+ max_tokens=MAX_TOKENS,
189
+ offline=offline_mode,
190
+ )
191
 
192
  log_start(task=str(args.task), env_name=str(args.benchmark), model=MODEL_NAME)
193
 
194
  try:
195
+ observation = await env.reset(task_id=str(args.task), difficulty=args.difficulty)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
196
 
197
  for step in range(1, max(1, int(args.max_steps)) + 1):
198
+ config_text, error_message, available_tools = _extract_observation_fields(observation)
 
 
 
199
 
200
  action_text = _select_action(
201
  model_wrapper=model_wrapper,
202
  step=step,
203
  config_text=config_text,
204
+ error_message=error_message,
205
  history=history,
206
+ available_actions=available_tools,
207
  policy_mode=str(args.policy_mode),
208
  trajectories=max(1, int(args.trajectories)),
209
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
210
 
211
+ observation, reward, done, info = await env.step(action_text)
212
+ step_error = _extract_error(info)
 
213
 
214
+ metrics.add_step(action=action_text, reward=float(reward), error=step_error, done=bool(done))
215
+ steps_taken = step
216
 
217
+ log_step(step=step, action=action_text, reward=float(reward), done=bool(done), error=step_error)
218
+ history.append(f"step={step} action={_single_line(action_text)} reward={float(reward):.2f}")
219
 
220
  if done:
221
+ episode_completed_cleanly = step_error is None and not _is_hacking_action(action_text)
222
  break
223
 
224
+ except Exception as exc:
225
  success = False
226
+ if not metrics.rewards:
227
+ metrics.add_step(action="system_error", reward=0.0, error=str(exc), done=True)
228
  finally:
229
+ score = max(0.0, min(1.0, float(metrics.average_reward)))
230
+ success = episode_completed_cleanly and score >= SUCCESS_SCORE_THRESHOLD
231
+
232
  try:
233
  save_reward_curve(metrics.rewards)
234
  save_metrics_json(metrics.summary())
 
236
  except Exception:
237
  pass
238
 
239
+ try:
240
+ await env.close()
241
+ except Exception:
242
+ pass
 
243
 
244
+ log_end(success=success, steps=steps_taken, score=score, rewards=metrics.rewards)
245
 
246
  return 0
247
 
inference/__pycache__/__init__.cpython-312.pyc DELETED
Binary file (316 Bytes)
 
inference/__pycache__/metrics.cpython-312.pyc DELETED
Binary file (3.58 kB)
 
inference/__pycache__/model_wrapper.cpython-312.pyc DELETED
Binary file (4.58 kB)
 
inference/__pycache__/prompts.cpython-312.pyc DELETED
Binary file (3.21 kB)
 
inference/__pycache__/visualize.cpython-312.pyc DELETED
Binary file (2.75 kB)
 
inference/model_wrapper.py CHANGED
@@ -5,7 +5,7 @@ from typing import Any, Iterable
5
 
6
  from openai import OpenAI
7
 
8
- from inference.prompts import SYSTEM_PROMPT, build_user_prompt, heuristic_action, sanitize_action_text
9
 
10
 
11
  @dataclass
@@ -24,8 +24,9 @@ class ModelWrapper:
24
  history: list[str],
25
  available_actions: Iterable[str] | None = None,
26
  ) -> str:
 
27
  if self.offline or self.client is None:
28
- return heuristic_action(config_text, error_message, available_actions)
29
 
30
  user_prompt = build_user_prompt(
31
  step=step,
@@ -42,18 +43,15 @@ class ModelWrapper:
42
  {"role": "system", "content": SYSTEM_PROMPT},
43
  {"role": "user", "content": user_prompt},
44
  ],
45
- temperature=self.temperature,
46
- max_tokens=self.max_tokens,
47
  stream=False,
48
  )
49
- generated = completion.choices[0].message.content or ""
50
- action = sanitize_action_text(
51
- generated,
52
- fallback=heuristic_action(config_text, error_message, available_actions),
53
- )
54
- return action
55
  except Exception:
56
- return heuristic_action(config_text, error_message, available_actions)
57
 
58
  def generate_candidates(
59
  self,
@@ -64,7 +62,7 @@ class ModelWrapper:
64
  count: int,
65
  available_actions: Iterable[str] | None = None,
66
  ) -> list[str]:
67
- candidates = [heuristic_action(config_text, error_message, available_actions)]
68
 
69
  for idx in range(max(1, count)):
70
  action = self.generate_action(
@@ -81,9 +79,7 @@ class ModelWrapper:
81
  seen: set[str] = set()
82
  for candidate in candidates:
83
  normalized = candidate.strip()
84
- if not normalized:
85
- continue
86
- if normalized in seen:
87
  continue
88
  seen.add(normalized)
89
  deduped.append(normalized)
@@ -96,14 +92,24 @@ def score_action_candidate(observation_text: str, action_text: str, hacking_pred
96
  action = (action_text or "").lower()
97
 
98
  score = 0.0
99
- if "npm tset" in obs and "npm test" in action:
 
 
 
 
 
 
 
100
  score += 2.0
101
- if "yaml" in obs and any(token in action for token in ("indent", "syntax", "yaml")):
102
- score += 1.0
103
- if "dependency" in obs and any(token in action for token in ("install", "dependency", "package")):
104
- score += 1.0
 
 
 
105
  if hacking_predicate(action_text):
106
  score -= 3.0
107
 
108
- score += min(len(action_text) / 200.0, 0.25)
109
  return score
 
5
 
6
  from openai import OpenAI
7
 
8
+ from inference.prompts import REQUIRED_ACTIONS, SYSTEM_PROMPT, build_user_prompt, heuristic_action, sanitize_action_text
9
 
10
 
11
  @dataclass
 
24
  history: list[str],
25
  available_actions: Iterable[str] | None = None,
26
  ) -> str:
27
+ fallback = heuristic_action(config_text, error_message, available_actions, history)
28
  if self.offline or self.client is None:
29
+ return fallback
30
 
31
  user_prompt = build_user_prompt(
32
  step=step,
 
43
  {"role": "system", "content": SYSTEM_PROMPT},
44
  {"role": "user", "content": user_prompt},
45
  ],
46
+ temperature=max(float(self.temperature), 0.0),
47
+ max_tokens=max(16, int(self.max_tokens)),
48
  stream=False,
49
  )
50
+
51
+ generated = str(completion.choices[0].message.content or "")
52
+ return sanitize_action_text(generated, fallback=fallback)
 
 
 
53
  except Exception:
54
+ return fallback
55
 
56
  def generate_candidates(
57
  self,
 
62
  count: int,
63
  available_actions: Iterable[str] | None = None,
64
  ) -> list[str]:
65
+ candidates = [heuristic_action(config_text, error_message, available_actions, history)]
66
 
67
  for idx in range(max(1, count)):
68
  action = self.generate_action(
 
79
  seen: set[str] = set()
80
  for candidate in candidates:
81
  normalized = candidate.strip()
82
+ if not normalized or normalized in seen:
 
 
83
  continue
84
  seen.add(normalized)
85
  deduped.append(normalized)
 
92
  action = (action_text or "").lower()
93
 
94
  score = 0.0
95
+
96
+ selected_tool = action.split(":", 1)[0].strip()
97
+ if selected_tool in REQUIRED_ACTIONS:
98
+ score += 0.5
99
+ else:
100
+ score -= 0.5
101
+
102
+ if "npm tset" in obs and "edit_config" in action and "npm test" in action:
103
  score += 2.0
104
+ if "yaml" in obs and "edit_config" in action:
105
+ score += 1.2
106
+ if "missing" in obs and "analyze_error" in action:
107
+ score += 0.8
108
+ if "validate_fix" in action or "submit_solution" in action:
109
+ score += 0.5
110
+
111
  if hacking_predicate(action_text):
112
  score -= 3.0
113
 
114
+ score += min(len(action_text) / 240.0, 0.2)
115
  return score
inference/prompts.py CHANGED
@@ -1,5 +1,6 @@
1
  from __future__ import annotations
2
 
 
3
  import textwrap
4
  from typing import Iterable
5
 
@@ -19,6 +20,17 @@ JUDGE_SYSTEM_PROMPT = textwrap.dedent(
19
  """
20
  ).strip()
21
 
 
 
 
 
 
 
 
 
 
 
 
22
 
23
  def build_user_prompt(
24
  step: int,
@@ -28,7 +40,7 @@ def build_user_prompt(
28
  available_actions: Iterable[str] | None = None,
29
  ) -> str:
30
  history_text = "\n".join(history[-5:]) if history else "None"
31
- actions_text = ", ".join(available_actions) if available_actions else "read_logs, analyze_error, propose_fix, edit_config, validate_fix"
32
 
33
  return textwrap.dedent(
34
  f"""
@@ -64,17 +76,50 @@ def heuristic_action(
64
  config_text: str,
65
  error_message: str,
66
  available_actions: Iterable[str] | None = None,
 
67
  ) -> str:
68
  lower_cfg = (config_text or "").lower()
69
  lower_err = (error_message or "").lower()
 
 
 
 
 
 
 
 
70
 
71
- if "npm tset" in lower_cfg:
 
 
 
72
  return "edit_config: replace npm tset with npm test"
73
 
74
- if "yaml" in lower_err or "mapping values are not allowed" in lower_err:
75
  return "edit_config: fix YAML indentation and syntax"
76
 
77
- if "module not found" in lower_err or "dependency" in lower_err:
78
- return "propose_fix: install missing dependency and update pipeline install step"
 
 
 
 
 
 
 
 
 
 
 
 
79
 
80
  return "read_logs: inspect failing stage logs and identify root cause"
 
 
 
 
 
 
 
 
 
 
1
  from __future__ import annotations
2
 
3
+ import re
4
  import textwrap
5
  from typing import Iterable
6
 
 
20
  """
21
  ).strip()
22
 
23
+ REQUIRED_ACTIONS = (
24
+ "read_file",
25
+ "read_logs",
26
+ "analyze_error",
27
+ "edit_config",
28
+ "run_pipeline_stage",
29
+ "run_tests",
30
+ "validate_fix",
31
+ "submit_solution",
32
+ )
33
+
34
 
35
  def build_user_prompt(
36
  step: int,
 
40
  available_actions: Iterable[str] | None = None,
41
  ) -> str:
42
  history_text = "\n".join(history[-5:]) if history else "None"
43
+ actions_text = ", ".join(available_actions) if available_actions else ", ".join(REQUIRED_ACTIONS)
44
 
45
  return textwrap.dedent(
46
  f"""
 
76
  config_text: str,
77
  error_message: str,
78
  available_actions: Iterable[str] | None = None,
79
+ history: list[str] | None = None,
80
  ) -> str:
81
  lower_cfg = (config_text or "").lower()
82
  lower_err = (error_message or "").lower()
83
+ seen = _extract_seen_tools(history or [])
84
+ allowed = {item.strip() for item in (available_actions or REQUIRED_ACTIONS)}
85
+
86
+ def has_tool(name: str) -> bool:
87
+ return name in allowed
88
+
89
+ if has_tool("read_logs") and "read_logs" not in seen:
90
+ return "read_logs: inspect failing stage logs"
91
 
92
+ if has_tool("analyze_error") and "analyze_error" not in seen:
93
+ return "analyze_error: identify root cause from logs and config"
94
+
95
+ if has_tool("edit_config") and "npm tset" in lower_cfg:
96
  return "edit_config: replace npm tset with npm test"
97
 
98
+ if has_tool("edit_config") and ("yaml" in lower_err or "mapping values are not allowed" in lower_err):
99
  return "edit_config: fix YAML indentation and syntax"
100
 
101
+ if has_tool("edit_config") and ("module not found" in lower_err or "dependency" in lower_err):
102
+ return "edit_config: repair dependency install and test commands"
103
+
104
+ if has_tool("run_pipeline_stage") and "run_pipeline_stage" not in seen:
105
+ return "run_pipeline_stage: run test stage"
106
+
107
+ if has_tool("run_tests") and "run_tests" not in seen:
108
+ return "run_tests: execute full pipeline tests"
109
+
110
+ if has_tool("validate_fix") and "validate_fix" not in seen:
111
+ return "validate_fix: check deterministic, hidden, and quality scores"
112
+
113
+ if has_tool("submit_solution"):
114
+ return "submit_solution: submit current configuration"
115
 
116
  return "read_logs: inspect failing stage logs and identify root cause"
117
+
118
+
119
+ def _extract_seen_tools(history: list[str]) -> set[str]:
120
+ seen: set[str] = set()
121
+ for item in history:
122
+ for tool in REQUIRED_ACTIONS:
123
+ if re.search(rf"\b{re.escape(tool)}\b", item):
124
+ seen.add(tool)
125
+ return seen
openenv.yaml CHANGED
@@ -1,69 +1,51 @@
1
- version: "0.1"
2
  name: "cicd-debugger-env"
3
- description: "AI environment for debugging CI/CD pipelines with deterministic + LLM grading"
4
 
5
- entrypoint:
6
- command: "python"
7
- args:
8
- - "inference.py"
9
 
10
  interface:
11
  observation_type: "json"
12
- action_type: "json"
13
  max_steps: 30
14
 
 
 
 
 
 
 
 
 
 
 
 
15
  tasks:
16
- - id: "cicd-debugger-001"
17
- description: "Fix typo in npm test command"
18
  difficulty: "easy"
19
- metadata:
20
- broken_token: "npm tset"
21
- fixed_token: "npm test"
22
-
23
- - id: "cicd-debugger-002"
24
- description: "Fix YAML indentation in test job"
25
  difficulty: "easy"
26
-
27
- - id: "cicd-debugger-003"
28
- description: "Fix missing checkout step"
29
- difficulty: "easy"
30
-
31
- - id: "cicd-debugger-004"
32
- description: "Fix wrong Python version pin"
33
  difficulty: "easy"
34
-
35
- - id: "cicd-debugger-005"
36
- description: "Fix dependency install command"
37
  difficulty: "medium"
38
-
39
- - id: "cicd-debugger-006"
40
- description: "Fix cache key mismatch"
41
  difficulty: "medium"
42
-
43
- - id: "cicd-debugger-007"
44
- description: "Fix environment variable propagation"
45
  difficulty: "medium"
46
-
47
- - id: "cicd-debugger-008"
48
- description: "Fix test stage permissions"
49
- difficulty: "medium"
50
-
51
- - id: "cicd-debugger-009"
52
- description: "Fix artifact upload path"
53
- difficulty: "medium"
54
-
55
- - id: "cicd-debugger-010"
56
- description: "Fix matrix include-exclude logic"
57
  difficulty: "hard"
58
-
59
- - id: "cicd-debugger-011"
60
- description: "Fix conditional deploy stage logic"
61
- difficulty: "hard"
62
-
63
- - id: "cicd-debugger-012"
64
- description: "Fix multi-job dependency ordering"
65
  difficulty: "hard"
66
-
67
- - id: "cicd-debugger-013"
68
- description: "Fix cross-platform shell command behavior"
69
  difficulty: "hard"
 
 
1
+ version: "0.2"
2
  name: "cicd-debugger-env"
3
+ description: "RL environment for CI/CD debugging with deterministic, hidden, and quality-aware scoring"
4
 
5
+ environment:
6
+ entry_point: "env.environment:CICDDebuggerEnvironment"
 
 
7
 
8
  interface:
9
  observation_type: "json"
10
+ action_type: "text"
11
  max_steps: 30
12
 
13
+ action_space:
14
+ tools:
15
+ - read_file
16
+ - read_logs
17
+ - analyze_error
18
+ - edit_config
19
+ - run_pipeline_stage
20
+ - run_tests
21
+ - validate_fix
22
+ - submit_solution
23
+
24
  tasks:
25
+ - id: "easy-command-typo"
 
26
  difficulty: "easy"
27
+ failure_stage: "test"
28
+ - id: "easy-missing-checkout"
 
 
 
 
29
  difficulty: "easy"
30
+ failure_stage: "build"
31
+ - id: "easy-yaml-indentation"
 
 
 
 
 
32
  difficulty: "easy"
33
+ failure_stage: "build"
34
+ - id: "medium-python-version"
 
35
  difficulty: "medium"
36
+ failure_stage: "build"
37
+ - id: "medium-cache-key"
 
38
  difficulty: "medium"
39
+ failure_stage: "test"
40
+ - id: "medium-artifact-permissions"
 
41
  difficulty: "medium"
42
+ failure_stage: "deploy"
43
+ - id: "hard-matrix-logic"
 
 
 
 
 
 
 
 
 
44
  difficulty: "hard"
45
+ failure_stage: "test"
46
+ - id: "hard-conditional-deploy"
 
 
 
 
 
47
  difficulty: "hard"
48
+ failure_stage: "deploy"
49
+ - id: "hard-needs-order"
 
50
  difficulty: "hard"
51
+ failure_stage: "deploy"
pyproject.toml CHANGED
@@ -5,13 +5,11 @@ description = "OpenEnv CI/CD pipeline debugging environment with hybrid grading
5
  readme = "README.md"
6
  requires-python = ">=3.10"
7
  dependencies = [
8
- "openai",
9
  "pyyaml",
10
  "fastapi",
11
  "uvicorn",
12
  "openenv-core",
13
- "transformers",
14
- "torch",
15
  ]
16
 
17
  [project.scripts]
 
5
  readme = "README.md"
6
  requires-python = ">=3.10"
7
  dependencies = [
 
8
  "pyyaml",
9
  "fastapi",
10
  "uvicorn",
11
  "openenv-core",
12
+ "openai",
 
13
  ]
14
 
15
  [project.scripts]
requirements.txt CHANGED
@@ -1,7 +1,5 @@
1
- openai
2
  pyyaml
3
  fastapi
4
  uvicorn
5
  openenv-core
6
- transformers
7
- torch
 
 
1
  pyyaml
2
  fastapi
3
  uvicorn
4
  openenv-core
5
+ openai
 
server/__pycache__/__init__.cpython-312.pyc DELETED
Binary file (156 Bytes)
 
server/__pycache__/app.cpython-312.pyc DELETED
Binary file (10.9 kB)
 
server/app.py CHANGED
@@ -2,59 +2,43 @@ from __future__ import annotations
2
 
3
  from dataclasses import dataclass
4
  import os
 
5
 
6
  from fastapi import FastAPI
7
  from fastapi import HTTPException
8
  from pydantic import BaseModel, Field
9
  import uvicorn
10
 
11
-
12
- app = FastAPI(title="CI/CD Debugger OpenEnv Server")
13
 
14
 
15
- DEFAULT_ORIGINAL_CONFIG = """
16
- name: CI
17
- on: [push]
18
- jobs:
19
- test:
20
- runs-on: ubuntu-latest
21
- steps:
22
- - uses: actions/checkout@v4
23
- - run: npm ci
24
- - run: npm tset
25
- """.strip()
26
-
27
- DEFAULT_EXPECTED_CONFIG = """
28
- name: CI
29
- on: [push]
30
- jobs:
31
- test:
32
- runs-on: ubuntu-latest
33
- steps:
34
- - uses: actions/checkout@v4
35
- - run: npm ci
36
- - run: npm test
37
- """.strip()
38
-
39
- DEFAULT_ERROR_MESSAGE = "command not found"
40
 
41
 
42
  class ObservationModel(BaseModel):
 
 
 
 
43
  config: str
44
- error_message: str
45
  logs: str
 
 
 
 
 
 
46
  last_action_error: str | None = None
47
 
48
 
49
  class ResetRequest(BaseModel):
50
- task_id: str = "cicd-debugger-task"
51
- original_config: str | None = None
52
- expected_config: str | None = None
53
- error_message: str | None = None
54
 
55
 
56
  class StepRequest(BaseModel):
57
- action: str = Field(min_length=1, max_length=600)
58
 
59
 
60
  class StepResponse(BaseModel):
@@ -64,6 +48,7 @@ class StepResponse(BaseModel):
64
  done: bool
65
  observation: ObservationModel
66
  last_action: str | None = None
 
67
 
68
 
69
  class StateResponse(BaseModel):
@@ -73,116 +58,52 @@ class StateResponse(BaseModel):
73
  done: bool = False
74
  last_action: str | None = None
75
  observation: ObservationModel | None = None
76
-
77
-
78
- @dataclass
79
- class LocalObservation:
80
- config: str
81
- error_message: str
82
- logs: str
83
- last_action_error: str | None = None
84
-
85
-
86
- @dataclass
87
- class LocalStepResult:
88
- observation: LocalObservation
89
- reward: float
90
- done: bool
91
- last_action_error: str | None = None
92
-
93
-
94
- class LocalCICDDebuggerEnv:
95
- def __init__(self, original_config: str, expected_config: str, error_message: str):
96
- self.original_config = original_config
97
- self.expected_config = expected_config
98
- self.error_message = error_message
99
- self.current_config = original_config
100
-
101
- def reset(self) -> LocalStepResult:
102
- self.current_config = self.original_config
103
- observation = LocalObservation(
104
- config=self.current_config,
105
- error_message=self.error_message,
106
- logs="CI failed in test step: npm tset is not a valid command.",
107
- )
108
- return LocalStepResult(observation=observation, reward=0.0, done=False)
109
-
110
- def step(self, action: str) -> LocalStepResult:
111
- action_text = str(action or "").strip()
112
- lower_action = action_text.lower()
113
- previous = self.current_config
114
- step_error: str | None = None
115
- logs = "No effective change applied."
116
-
117
- if _is_hacking_action(action_text):
118
- step_error = "disallowed_hacking_pattern"
119
- logs = "Rejected unsafe action pattern."
120
- elif "npm tset" in lower_action and "npm test" in lower_action and "npm tset" in previous:
121
- self.current_config = previous.replace("npm tset", "npm test")
122
- logs = "Patched CI command typo from npm tset to npm test."
123
- elif "replace" in lower_action and "npm test" in lower_action and "npm tset" in previous:
124
- self.current_config = previous.replace("npm tset", "npm test")
125
- logs = "Applied replace operation for broken test command."
126
- elif "npm test" in lower_action and "npm tset" in previous:
127
- self.current_config = previous.replace("npm tset", "npm test")
128
- logs = "Applied inferred command fix."
129
-
130
- done = "npm tset" not in self.current_config.lower() and "npm test" in self.current_config.lower()
131
- reward = 1.0 if done else 0.0
132
- error_message = "" if done else self.error_message
133
-
134
- observation = LocalObservation(
135
- config=self.current_config,
136
- error_message=error_message,
137
- logs=logs,
138
- last_action_error=step_error,
139
- )
140
- return LocalStepResult(observation=observation, reward=reward, done=done, last_action_error=step_error)
141
 
142
 
143
  @dataclass
144
  class RuntimeSession:
 
145
  task_id: str
146
- env: LocalCICDDebuggerEnv
147
  step_count: int = 0
148
  done: bool = False
149
  last_action: str | None = None
150
- last_result: LocalStepResult | None = None
 
 
151
 
152
 
153
  runtime_session: RuntimeSession | None = None
154
 
155
 
156
- def _is_hacking_action(action_text: str) -> bool:
157
- value = (action_text or "").lower()
158
- patterns = (
159
- "if: false",
160
- "when: never",
161
- "echo \"tests passed\"",
162
- "echo 'tests passed'",
163
- "exit 0",
164
- "force success",
165
- )
166
- return any(token in value for token in patterns)
167
-
168
-
169
- def _as_observation_model(observation: LocalObservation) -> ObservationModel:
170
  return ObservationModel(
171
- config=observation.config,
172
- error_message=observation.error_message,
173
- logs=observation.logs,
174
- last_action_error=observation.last_action_error,
 
 
 
 
 
 
 
 
 
175
  )
176
 
177
 
178
- def _build_step_response(session: RuntimeSession, result: LocalStepResult) -> StepResponse:
 
179
  return StepResponse(
180
  task_id=session.task_id,
181
- step_count=session.step_count,
182
- reward=float(result.reward),
183
- done=bool(result.done),
184
- observation=_as_observation_model(result.observation),
185
  last_action=session.last_action,
 
186
  )
187
 
188
 
@@ -192,48 +113,56 @@ def health() -> dict[str, str]:
192
 
193
 
194
  @app.post("/reset", response_model=StepResponse)
195
- def reset(payload: ResetRequest | None = None) -> StepResponse:
196
  global runtime_session
197
 
198
  request = payload or ResetRequest()
199
- env = LocalCICDDebuggerEnv(
200
- original_config=request.original_config or DEFAULT_ORIGINAL_CONFIG,
201
- expected_config=request.expected_config or DEFAULT_EXPECTED_CONFIG,
202
- error_message=request.error_message or DEFAULT_ERROR_MESSAGE,
 
 
 
 
 
 
 
 
203
  )
204
- result = env.reset()
205
-
206
- runtime_session = RuntimeSession(task_id=request.task_id, env=env, last_result=result)
207
- return _build_step_response(runtime_session, result)
208
 
209
 
210
  @app.post("/step", response_model=StepResponse)
211
- def step(payload: StepRequest) -> StepResponse:
212
  global runtime_session
213
 
214
  if runtime_session is None:
215
  raise HTTPException(status_code=400, detail="Environment not initialized. Call /reset first.")
216
 
217
- if runtime_session.done and runtime_session.last_result is not None:
218
- return _build_step_response(runtime_session, runtime_session.last_result)
 
 
219
 
220
- result = runtime_session.env.step(payload.action)
221
- runtime_session.step_count += 1
222
- runtime_session.done = bool(result.done)
223
- runtime_session.last_action = payload.action
224
- runtime_session.last_result = result
 
225
 
226
- return _build_step_response(runtime_session, result)
227
 
228
 
229
  @app.get("/state", response_model=StateResponse)
230
- def state() -> StateResponse:
231
  if runtime_session is None:
232
  return StateResponse(initialized=False)
233
 
234
  observation = None
235
- if runtime_session.last_result is not None:
236
- observation = _as_observation_model(runtime_session.last_result.observation)
237
 
238
  return StateResponse(
239
  initialized=True,
@@ -242,12 +171,13 @@ def state() -> StateResponse:
242
  done=runtime_session.done,
243
  last_action=runtime_session.last_action,
244
  observation=observation,
 
245
  )
246
 
247
 
248
  @app.post("/state", response_model=StateResponse)
249
- def state_post() -> StateResponse:
250
- return state()
251
 
252
 
253
  def main() -> None:
 
2
 
3
  from dataclasses import dataclass
4
  import os
5
+ from typing import Any
6
 
7
  from fastapi import FastAPI
8
  from fastapi import HTTPException
9
  from pydantic import BaseModel, Field
10
  import uvicorn
11
 
12
+ from env.environment import CICDDebuggerEnvironment, MAX_STEPS
 
13
 
14
 
15
+ app = FastAPI(title="CI/CD Debugger OpenEnv Server")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
 
17
 
18
  class ObservationModel(BaseModel):
19
+ task_id: str
20
+ difficulty: str
21
+ failure_stage: str
22
+ actual_bug: str
23
  config: str
 
24
  logs: str
25
+ error_message: str
26
+ available_tools: list[str]
27
+ progress_flags: dict[str, bool]
28
+ file_modification_count: int
29
+ hidden_test_pass_rate: float
30
+ step_count: int
31
  last_action_error: str | None = None
32
 
33
 
34
  class ResetRequest(BaseModel):
35
+ task_id: str | None = None
36
+ difficulty: str | None = None
37
+ max_steps: int = Field(default=MAX_STEPS, ge=1, le=100)
 
38
 
39
 
40
  class StepRequest(BaseModel):
41
+ action: str | dict[str, Any]
42
 
43
 
44
  class StepResponse(BaseModel):
 
48
  done: bool
49
  observation: ObservationModel
50
  last_action: str | None = None
51
+ info: dict[str, Any] = Field(default_factory=dict)
52
 
53
 
54
  class StateResponse(BaseModel):
 
58
  done: bool = False
59
  last_action: str | None = None
60
  observation: ObservationModel | None = None
61
+ internal_state: dict[str, Any] = Field(default_factory=dict)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
 
63
 
64
  @dataclass
65
  class RuntimeSession:
66
+ env: CICDDebuggerEnvironment
67
  task_id: str
 
68
  step_count: int = 0
69
  done: bool = False
70
  last_action: str | None = None
71
+ last_reward: float = 0.0
72
+ last_observation: dict[str, Any] | None = None
73
+ last_info: dict[str, Any] | None = None
74
 
75
 
76
  runtime_session: RuntimeSession | None = None
77
 
78
 
79
+ def _as_observation_model(observation: dict[str, Any]) -> ObservationModel:
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  return ObservationModel(
81
+ task_id=str(observation.get("task_id", "")),
82
+ difficulty=str(observation.get("difficulty", "")),
83
+ failure_stage=str(observation.get("failure_stage", "")),
84
+ actual_bug=str(observation.get("actual_bug", "")),
85
+ config=str(observation.get("config", "")),
86
+ logs=str(observation.get("logs", "")),
87
+ error_message=str(observation.get("error_message", "")),
88
+ available_tools=list(observation.get("available_tools") or []),
89
+ progress_flags=dict(observation.get("progress_flags") or {}),
90
+ file_modification_count=int(observation.get("file_modification_count") or 0),
91
+ hidden_test_pass_rate=float(observation.get("hidden_test_pass_rate") or 0.0),
92
+ step_count=int(observation.get("step_count") or 0),
93
+ last_action_error=observation.get("last_action_error"),
94
  )
95
 
96
 
97
+ def _build_step_response(session: RuntimeSession) -> StepResponse:
98
+ observation = session.last_observation or {}
99
  return StepResponse(
100
  task_id=session.task_id,
101
+ step_count=int(observation.get("step_count") or session.step_count),
102
+ reward=float(session.last_reward),
103
+ done=bool(session.done),
104
+ observation=_as_observation_model(observation),
105
  last_action=session.last_action,
106
+ info=session.last_info or {},
107
  )
108
 
109
 
 
113
 
114
 
115
  @app.post("/reset", response_model=StepResponse)
116
+ async def reset(payload: ResetRequest | None = None) -> StepResponse:
117
  global runtime_session
118
 
119
  request = payload or ResetRequest()
120
+ env = CICDDebuggerEnvironment(max_steps=int(request.max_steps))
121
+ observation = await env.reset(task_id=request.task_id, difficulty=request.difficulty)
122
+
123
+ runtime_session = RuntimeSession(
124
+ env=env,
125
+ task_id=str(observation.get("task_id", request.task_id or "cicd-debugger-task")),
126
+ step_count=0,
127
+ done=False,
128
+ last_action=None,
129
+ last_reward=0.0,
130
+ last_observation=observation,
131
+ last_info={"message": "environment reset", "tool": "reset", "error": None},
132
  )
133
+ return _build_step_response(runtime_session)
 
 
 
134
 
135
 
136
  @app.post("/step", response_model=StepResponse)
137
+ async def step(payload: StepRequest) -> StepResponse:
138
  global runtime_session
139
 
140
  if runtime_session is None:
141
  raise HTTPException(status_code=400, detail="Environment not initialized. Call /reset first.")
142
 
143
+ if runtime_session.done:
144
+ return _build_step_response(runtime_session)
145
+
146
+ observation, reward, done, info = await runtime_session.env.step(payload.action)
147
 
148
+ runtime_session.step_count = int(observation.get("step_count", runtime_session.step_count + 1))
149
+ runtime_session.done = bool(done)
150
+ runtime_session.last_action = payload.action if isinstance(payload.action, str) else str(payload.action)
151
+ runtime_session.last_reward = float(reward)
152
+ runtime_session.last_observation = observation
153
+ runtime_session.last_info = dict(info or {})
154
 
155
+ return _build_step_response(runtime_session)
156
 
157
 
158
  @app.get("/state", response_model=StateResponse)
159
+ async def state() -> StateResponse:
160
  if runtime_session is None:
161
  return StateResponse(initialized=False)
162
 
163
  observation = None
164
+ if runtime_session.last_observation is not None:
165
+ observation = _as_observation_model(runtime_session.last_observation)
166
 
167
  return StateResponse(
168
  initialized=True,
 
171
  done=runtime_session.done,
172
  last_action=runtime_session.last_action,
173
  observation=observation,
174
+ internal_state=runtime_session.env.get_state(),
175
  )
176
 
177
 
178
  @app.post("/state", response_model=StateResponse)
179
+ async def state_post() -> StateResponse:
180
+ return await state()
181
 
182
 
183
  def main() -> None:
tests/__pycache__/test_day2_engine.cpython-312.pyc DELETED
Binary file (8.69 kB)
 
tests/__pycache__/test_inference.cpython-312.pyc DELETED
Binary file (3.13 kB)
 
tests/__pycache__/test_judge.cpython-312.pyc DELETED
Binary file (3.52 kB)
 
tests/__pycache__/test_server_api.cpython-312.pyc DELETED
Binary file (3.22 kB)
 
tests/test_env.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import unittest
3
+
4
+ from env.environment import CICDDebuggerEnvironment, REQUIRED_TOOLS
5
+
6
+
7
+ class EnvironmentContractTests(unittest.TestCase):
8
+ def test_reset_returns_structured_observation(self):
9
+ env = CICDDebuggerEnvironment(max_steps=10, seed=7)
10
+ observation = asyncio.run(env.reset(task_id="easy-command-typo"))
11
+
12
+ self.assertIn("config", observation)
13
+ self.assertIn("logs", observation)
14
+ self.assertIn("error_message", observation)
15
+ self.assertIn("progress_flags", observation)
16
+ self.assertEqual(observation["task_id"], "easy-command-typo")
17
+ self.assertEqual(observation["available_tools"], REQUIRED_TOOLS)
18
+ self.assertEqual(observation["step_count"], 0)
19
+
20
+ def test_step_returns_obs_reward_done_info(self):
21
+ env = CICDDebuggerEnvironment(max_steps=10, seed=3)
22
+ asyncio.run(env.reset(task_id="easy-command-typo"))
23
+
24
+ observation, reward, done, info = asyncio.run(env.step("read_logs: inspect failing stage logs"))
25
+
26
+ self.assertIsInstance(observation, dict)
27
+ self.assertIsInstance(reward, float)
28
+ self.assertIsInstance(done, bool)
29
+ self.assertIsInstance(info, dict)
30
+ self.assertIn("tool", info)
31
+
32
+ def test_action_space_rejects_extra_tools(self):
33
+ env = CICDDebuggerEnvironment(max_steps=10, seed=5)
34
+ asyncio.run(env.reset(task_id="easy-command-typo"))
35
+
36
+ observation, reward, done, info = asyncio.run(env.step("propose_fix: force deploy"))
37
+
38
+ self.assertIn("error", info)
39
+ self.assertIsNotNone(info["error"])
40
+ self.assertFalse(done)
41
+ self.assertGreaterEqual(reward, 0.0)
42
+ self.assertIn("config", observation)
43
+
44
+ def test_action_space_rejects_alias_tools(self):
45
+ env = CICDDebuggerEnvironment(max_steps=10, seed=15)
46
+ asyncio.run(env.reset(task_id="easy-command-typo"))
47
+
48
+ _, _, done, info = asyncio.run(env.step("read: workflow file"))
49
+
50
+ self.assertIn("error", info)
51
+ self.assertIsNotNone(info["error"])
52
+ self.assertFalse(done)
53
+
54
+ def test_submit_solution_path(self):
55
+ env = CICDDebuggerEnvironment(max_steps=12, seed=9)
56
+ asyncio.run(env.reset(task_id="easy-command-typo"))
57
+
58
+ asyncio.run(env.step("read_logs: inspect logs"))
59
+ asyncio.run(env.step("analyze_error: identify root cause"))
60
+ asyncio.run(env.step("edit_config: replace npm tset with npm test"))
61
+ asyncio.run(env.step("run_pipeline_stage: run test stage"))
62
+ asyncio.run(env.step("run_tests: execute tests"))
63
+ asyncio.run(env.step("validate_fix: validate score"))
64
+ observation, reward, done, info = asyncio.run(env.step("submit_solution: submit current fix"))
65
+
66
+ self.assertTrue(done)
67
+ self.assertGreaterEqual(reward, 0.0)
68
+ self.assertIsNone(info.get("error"))
69
+ self.assertEqual(observation["progress_flags"].get("submit_solution"), True)
70
+
71
+ def test_internal_state_tracks_required_fields(self):
72
+ env = CICDDebuggerEnvironment(max_steps=10, seed=11)
73
+ asyncio.run(env.reset(task_id="easy-command-typo"))
74
+ asyncio.run(env.step("read_logs: inspect logs"))
75
+
76
+ state = env.get_state()
77
+ self.assertTrue(state.get("initialized"))
78
+ self.assertIn("actual_bug", state)
79
+ self.assertIn("correct_solution", state)
80
+ self.assertIn("progress_flags", state)
81
+ self.assertIn("file_modification_count", state)
82
+ self.assertIn("hidden_test_pass_rate", state)
83
+
84
+ def test_yaml_task_is_fixable_via_edit_flow(self):
85
+ env = CICDDebuggerEnvironment(max_steps=12, seed=17)
86
+ asyncio.run(env.reset(task_id="easy-yaml-indentation"))
87
+
88
+ asyncio.run(env.step("read_logs: inspect logs"))
89
+ asyncio.run(env.step("analyze_error: identify root cause"))
90
+ observation, _, _, _ = asyncio.run(env.step("edit_config: fix YAML indentation and syntax"))
91
+
92
+ self.assertIn("- run: pytest", observation["config"])
93
+ self.assertNotIn(" - run: pytest", observation["config"])
94
+
95
+ asyncio.run(env.step("run_tests: execute tests"))
96
+ asyncio.run(env.step("validate_fix: validate score"))
97
+ _, _, done, info = asyncio.run(env.step("submit_solution: submit current fix"))
98
+
99
+ self.assertTrue(done)
100
+ self.assertIsNone(info.get("error"))
101
+
102
+ def test_hard_needs_order_edit_updates_deploy_dependency(self):
103
+ env = CICDDebuggerEnvironment(max_steps=12, seed=19)
104
+ asyncio.run(env.reset(task_id="hard-needs-order"))
105
+
106
+ observation, _, _, _ = asyncio.run(env.step("edit_config: fix deploy dependency ordering"))
107
+
108
+ self.assertIn("needs: [build, test]", observation["config"])
109
+ self.assertEqual(observation["config"].count("needs: build"), 1)
110
+
111
+
112
+ if __name__ == "__main__":
113
+ unittest.main()
tests/test_inference.py CHANGED
@@ -31,7 +31,7 @@ class InferenceOutputFormatTests(unittest.TestCase):
31
  r"^\[STEP\] step=\d+ action=.* reward=-?\d+\.\d{2} done=(true|false) error=(null|.+)$"
32
  )
33
  end_pattern = re.compile(
34
- r"^\[END\] success=(true|false) steps=\d+ rewards=(-?\d+\.\d{2}(,-?\d+\.\d{2})*)?$"
35
  )
36
 
37
  self.assertRegex(lines[0], start_pattern)
 
31
  r"^\[STEP\] step=\d+ action=.* reward=-?\d+\.\d{2} done=(true|false) error=(null|.+)$"
32
  )
33
  end_pattern = re.compile(
34
+ r"^\[END\] success=(true|false) steps=\d+ score=\d+\.\d{3} rewards=(-?\d+\.\d{2}(,-?\d+\.\d{2})*)?$"
35
  )
36
 
37
  self.assertRegex(lines[0], start_pattern)
validate-submission.sh CHANGED
@@ -2,15 +2,32 @@
2
  #
3
  # validate-submission.sh - OpenEnv Submission Validator
4
  #
5
- # Checks all mandatory pre-submission gates for this challenge.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  #
7
- # Usage:
8
- # ./validate-submission.sh <ping_url> [repo_dir]
9
 
10
  set -uo pipefail
11
 
12
  DOCKER_BUILD_TIMEOUT=600
13
- INFERENCE_TIMEOUT=1200
14
  if [ -t 1 ]; then
15
  RED='\033[0;31m'
16
  GREEN='\033[0;32m'
@@ -64,22 +81,9 @@ if ! REPO_DIR="$(cd "$REPO_DIR" 2>/dev/null && pwd)"; then
64
  printf "Error: directory '%s' not found\n" "${2:-.}"
65
  exit 1
66
  fi
67
-
68
  PING_URL="${PING_URL%/}"
 
69
  PASS=0
70
- PYTHON_CMD="python3"
71
- if [ -x "$REPO_DIR/.venv/bin/python" ]; then
72
- PYTHON_CMD="$REPO_DIR/.venv/bin/python"
73
- fi
74
-
75
- run_openenv_validate() {
76
- if command -v openenv &>/dev/null; then
77
- if (cd "$REPO_DIR" && openenv validate); then
78
- return 0
79
- fi
80
- fi
81
- (cd "$REPO_DIR" && "$PYTHON_CMD" -m openenv.cli.__main__ validate)
82
- }
83
 
84
  log() { printf "[%s] %b\n" "$(date -u +%H:%M:%S)" "$*"; }
85
  pass() { log "${GREEN}PASSED${NC} -- $1"; PASS=$((PASS + 1)); }
@@ -97,43 +101,9 @@ printf "${BOLD} OpenEnv Submission Validator${NC}\n"
97
  printf "${BOLD}========================================${NC}\n"
98
  log "Repo: $REPO_DIR"
99
  log "Ping URL: $PING_URL"
100
- log "Python: $PYTHON_CMD"
101
  printf "\n"
102
 
103
- log "${BOLD}Step 1/8: Checking mandatory environment variables${NC} ..."
104
-
105
- REQUIRED_VARS=(API_BASE_URL MODEL_NAME HF_TOKEN)
106
- MISSING_VARS=()
107
- for var_name in "${REQUIRED_VARS[@]}"; do
108
- if [ -z "${!var_name:-}" ]; then
109
- MISSING_VARS+=("$var_name")
110
- fi
111
- done
112
-
113
- if [ ${#MISSING_VARS[@]} -eq 0 ]; then
114
- pass "Required environment variables are set (API_BASE_URL, MODEL_NAME, HF_TOKEN)"
115
- else
116
- fail "Missing required environment variables: ${MISSING_VARS[*]}"
117
- hint "Export them before submission, for example: export API_BASE_URL=... MODEL_NAME=... HF_TOKEN=..."
118
- stop_at "Step 1"
119
- fi
120
-
121
- log "${BOLD}Step 2/8: Verifying inference entrypoint and OpenAI client usage${NC} ..."
122
-
123
- if [ ! -f "$REPO_DIR/inference.py" ]; then
124
- fail "inference.py not found at repository root"
125
- stop_at "Step 2"
126
- fi
127
-
128
- if grep -Eq "from[[:space:]]+openai[[:space:]]+import[[:space:]]+OpenAI|openai\\.OpenAI|OpenAI\\(" "$REPO_DIR/inference.py"; then
129
- pass "Root inference.py exists and uses OpenAI client"
130
- else
131
- fail "Could not verify OpenAI client usage in inference.py"
132
- hint "Use: from openai import OpenAI and initialize client with API_BASE_URL/MODEL_NAME/HF_TOKEN"
133
- stop_at "Step 2"
134
- fi
135
-
136
- log "${BOLD}Step 3/8: Pinging HF Space endpoints${NC} ($PING_URL/reset, /state, /step) ..."
137
 
138
  CURL_OUTPUT=$(portable_mktemp "validate-curl")
139
  CLEANUP_FILES+=("$CURL_OUTPUT")
@@ -146,50 +116,21 @@ if [ "$HTTP_CODE" = "200" ]; then
146
  elif [ "$HTTP_CODE" = "000" ]; then
147
  fail "HF Space not reachable (connection failed or timed out)"
148
  hint "Check your network connection and that the Space is running."
149
- hint "Try: curl -s -o /dev/null -w '%{http_code}' -X POST $PING_URL/reset"
150
- stop_at "Step 3"
151
  else
152
  fail "HF Space /reset returned HTTP $HTTP_CODE (expected 200)"
153
  hint "Make sure your Space is running and the URL is correct."
154
- stop_at "Step 3"
155
- fi
156
-
157
- STATE_OUTPUT=$(portable_mktemp "validate-state")
158
- CLEANUP_FILES+=("$STATE_OUTPUT")
159
- STATE_CODE=$(curl -s -o "$STATE_OUTPUT" -w "%{http_code}" "$PING_URL/state" --max-time 30 || printf "000")
160
- if [ "$STATE_CODE" != "200" ]; then
161
- STATE_CODE=$(curl -s -o "$STATE_OUTPUT" -w "%{http_code}" -X POST \
162
- -H "Content-Type: application/json" -d '{}' \
163
- "$PING_URL/state" --max-time 30 || printf "000")
164
- fi
165
-
166
- if [ "$STATE_CODE" = "200" ]; then
167
- pass "HF Space responds to /state"
168
- else
169
- fail "HF Space /state returned HTTP $STATE_CODE (expected 200)"
170
- stop_at "Step 3"
171
- fi
172
-
173
- STEP_OUTPUT=$(portable_mktemp "validate-step")
174
- CLEANUP_FILES+=("$STEP_OUTPUT")
175
- STEP_CODE=$(curl -s -o "$STEP_OUTPUT" -w "%{http_code}" -X POST \
176
- -H "Content-Type: application/json" \
177
- -d '{"action":"read_logs: inspect failing stage logs"}' \
178
- "$PING_URL/step" --max-time 30 || printf "000")
179
-
180
- if [ "$STEP_CODE" = "200" ]; then
181
- pass "HF Space responds to /step"
182
- else
183
- fail "HF Space /step returned HTTP $STEP_CODE (expected 200)"
184
- stop_at "Step 3"
185
  fi
186
 
187
- log "${BOLD}Step 4/8: Running docker build${NC} ..."
188
 
189
  if ! command -v docker &>/dev/null; then
190
  fail "docker command not found"
191
  hint "Install Docker: https://docs.docker.com/get-docker/"
192
- stop_at "Step 4"
193
  fi
194
 
195
  if [ -f "$REPO_DIR/Dockerfile" ]; then
@@ -198,9 +139,11 @@ elif [ -f "$REPO_DIR/server/Dockerfile" ]; then
198
  DOCKER_CONTEXT="$REPO_DIR/server"
199
  else
200
  fail "No Dockerfile found in repo root or server/ directory"
201
- stop_at "Step 4"
202
  fi
203
 
 
 
204
  BUILD_OK=false
205
  BUILD_OUTPUT=$(run_with_timeout "$DOCKER_BUILD_TIMEOUT" docker build "$DOCKER_CONTEXT" 2>&1) && BUILD_OK=true
206
 
@@ -209,192 +152,34 @@ if [ "$BUILD_OK" = true ]; then
209
  else
210
  fail "Docker build failed (timeout=${DOCKER_BUILD_TIMEOUT}s)"
211
  printf "%s\n" "$BUILD_OUTPUT" | tail -20
212
- stop_at "Step 4"
213
  fi
214
 
215
- log "${BOLD}Step 5/8: Running openenv validate${NC} ..."
216
 
217
  VALIDATE_OK=false
218
- VALIDATE_OUTPUT=$(run_openenv_validate 2>&1) && VALIDATE_OK=true
 
 
 
 
 
 
 
 
219
 
220
  if [ "$VALIDATE_OK" = true ]; then
221
  pass "openenv validate passed"
 
222
  else
223
  fail "openenv validate failed"
224
  printf "%s\n" "$VALIDATE_OUTPUT"
225
- stop_at "Step 5"
226
- fi
227
-
228
- log "${BOLD}Step 6/8: Running baseline inference under 20-minute limit${NC} ..."
229
-
230
- INFER_OK=false
231
- INFER_OUTPUT=$(run_with_timeout "$INFERENCE_TIMEOUT" env OFFLINE_INFERENCE=1 \
232
- "$PYTHON_CMD" "$REPO_DIR/inference.py" --max-steps 8 --offline --force-local-env 2>&1) && INFER_OK=true
233
-
234
- if [ "$INFER_OK" != true ]; then
235
- fail "inference.py failed or exceeded ${INFERENCE_TIMEOUT}s"
236
- printf "%s\n" "$INFER_OUTPUT" | tail -40
237
- stop_at "Step 6"
238
- fi
239
-
240
- NON_EMPTY_OUTPUT=$(printf "%s\n" "$INFER_OUTPUT" | sed '/^[[:space:]]*$/d')
241
- FIRST_LINE=$(printf "%s\n" "$NON_EMPTY_OUTPUT" | head -n 1)
242
- LAST_LINE=$(printf "%s\n" "$NON_EMPTY_OUTPUT" | tail -n 1)
243
-
244
- if ! printf "%s\n" "$FIRST_LINE" | grep -Eq '^\[START\] task=[^ ]+ env=[^ ]+ model=.+$'; then
245
- fail "Missing or malformed [START] line"
246
- printf "%s\n" "$INFER_OUTPUT" | tail -40
247
- stop_at "Step 6"
248
- fi
249
-
250
- if ! printf "%s\n" "$NON_EMPTY_OUTPUT" | grep -Eq '^\[STEP\] step=[0-9]+ action=.* reward=-?[0-9]+\.[0-9]{2} done=(true|false) error=(null|.+)$'; then
251
- fail "Missing or malformed [STEP] line"
252
- printf "%s\n" "$INFER_OUTPUT" | tail -40
253
- stop_at "Step 6"
254
- fi
255
-
256
- if ! printf "%s\n" "$LAST_LINE" | grep -Eq '^\[END\] success=(true|false) steps=[0-9]+ rewards=(-?[0-9]+\.[0-9]{2}(,-?[0-9]+\.[0-9]{2})*)?$'; then
257
- fail "Missing or malformed [END] line"
258
- printf "%s\n" "$INFER_OUTPUT" | tail -40
259
- stop_at "Step 6"
260
- fi
261
-
262
- pass "Baseline inference completed and emitted strict [START]/[STEP]/[END] format"
263
-
264
- log "${BOLD}Step 7/8: Validating 3+ tasks with graders (scores/reward in 0.0-1.0)${NC} ..."
265
-
266
- PY_CHECK_SCRIPT=$(portable_mktemp "validate-grader")
267
- CLEANUP_FILES+=("$PY_CHECK_SCRIPT")
268
- cat >"$PY_CHECK_SCRIPT" <<'PY'
269
- from __future__ import annotations
270
-
271
- import json
272
- import sys
273
- from pathlib import Path
274
-
275
- import yaml
276
-
277
- repo = Path(sys.argv[1]).resolve()
278
- sys.path.insert(0, str(repo))
279
-
280
- from env.graders.deterministic import DeterministicGrader
281
- from env.hidden_tests import HiddenTestRunner
282
- from env.rewards import RewardCalculator
283
-
284
-
285
- def build_config(command: str) -> str:
286
- return f"""
287
- name: CI
288
- on: [push]
289
- jobs:
290
- test:
291
- runs-on: ubuntu-latest
292
- steps:
293
- - uses: actions/checkout@v4
294
- - run: npm ci
295
- - run: {command}
296
- """.strip()
297
-
298
-
299
- config_path = repo / "openenv.yaml"
300
- data = yaml.safe_load(config_path.read_text(encoding="utf-8"))
301
- tasks = data.get("tasks", [])
302
- if len(tasks) < 3:
303
- raise SystemExit("expected at least 3 tasks in openenv.yaml")
304
-
305
- grader = DeterministicGrader()
306
- hidden = HiddenTestRunner(grader=grader)
307
- reward_calculator = RewardCalculator()
308
-
309
- results = []
310
- for task in tasks[:3]:
311
- metadata = dict(task.get("metadata") or {})
312
- broken_token = str(metadata.get("broken_token", "npm tset"))
313
- fixed_token = str(metadata.get("fixed_token", "npm test"))
314
-
315
- original_config = build_config(broken_token)
316
- fixed_config = build_config(fixed_token)
317
-
318
- deterministic_score = float(grader.grade(fixed_config, fixed_config, metadata))
319
- hidden_score = float(hidden.evaluate_fix(fixed_config, expected_config=fixed_config, metadata=metadata))
320
- reward_score = float(
321
- reward_calculator.calculate_step_reward(
322
- state={
323
- "step_count": 1,
324
- "previous_config": original_config,
325
- "expected_config": fixed_config,
326
- "original_config": original_config,
327
- "error": "command not found",
328
- },
329
- action="validate_fix",
330
- result={
331
- "previous_config": original_config,
332
- "current_config": fixed_config,
333
- "expected_config": fixed_config,
334
- "logs_analyzed": True,
335
- "error_diagnosed": True,
336
- "fix_proposed": True,
337
- "pipeline_run": True,
338
- "tests_passed": True,
339
- "command_succeeded": True,
340
- "changed_files_count": 1,
341
- "changed_lines_count": 2,
342
- },
343
- original_config=original_config,
344
- fixed_config=fixed_config,
345
- error_message="command not found",
346
- expected_config=fixed_config,
347
- metadata=metadata,
348
- )
349
- )
350
-
351
- for label, value in (
352
- ("deterministic", deterministic_score),
353
- ("hidden", hidden_score),
354
- ("reward", reward_score),
355
- ):
356
- if value < 0.0 or value > 1.0:
357
- raise SystemExit(f"{task.get('id', 'unknown')} {label} out of range: {value}")
358
-
359
- results.append(
360
- {
361
- "id": task.get("id", "unknown"),
362
- "deterministic": round(deterministic_score, 4),
363
- "hidden": round(hidden_score, 4),
364
- "reward": round(reward_score, 4),
365
- }
366
- )
367
-
368
- print(json.dumps(results, indent=2, sort_keys=True))
369
- PY
370
-
371
- GRADER_OK=false
372
- GRADER_OUTPUT=$(run_with_timeout 180 "$PYTHON_CMD" "$PY_CHECK_SCRIPT" "$REPO_DIR" 2>&1) && GRADER_OK=true
373
-
374
- if [ "$GRADER_OK" = true ]; then
375
- pass "Grader checks passed on 3 tasks with scores/reward in [0.0, 1.0]"
376
- else
377
- fail "Grader range check failed"
378
- printf "%s\n" "$GRADER_OUTPUT"
379
- stop_at "Step 7"
380
- fi
381
-
382
- log "${BOLD}Step 8/8: Verifying unit test suite${NC} ..."
383
-
384
- TESTS_OK=false
385
- TESTS_OUTPUT=$(run_with_timeout 300 "$PYTHON_CMD" -m unittest discover -s "$REPO_DIR/tests" -v 2>&1) && TESTS_OK=true
386
-
387
- if [ "$TESTS_OK" = true ]; then
388
- pass "Unit tests passed"
389
- else
390
- fail "Unit tests failed"
391
- printf "%s\n" "$TESTS_OUTPUT" | tail -40
392
- stop_at "Step 8"
393
  fi
394
 
395
  printf "\n"
396
  printf "${BOLD}========================================${NC}\n"
397
- printf "${GREEN}${BOLD} All 8/8 checks passed!${NC}\n"
398
  printf "${GREEN}${BOLD} Your submission is ready to submit.${NC}\n"
399
  printf "${BOLD}========================================${NC}\n"
400
  printf "\n"
 
2
  #
3
  # validate-submission.sh - OpenEnv Submission Validator
4
  #
5
+ # Checks that your HF Space is live, Docker image builds, and openenv validate passes.
6
+ #
7
+ # Prerequisites:
8
+ # - Docker: https://docs.docker.com/get-docker/
9
+ # - openenv-core: pip install openenv-core
10
+ # - curl (usually pre-installed)
11
+ #
12
+ # Run:
13
+ # curl -fsSL https://raw.githubusercontent.com/<owner>/<repo>/main/scripts/validate-submission.sh | bash -s -- <ping_url> [repo_dir]
14
+ #
15
+ # Or download and run locally:
16
+ # chmod +x validate-submission.sh
17
+ # ./validate-submission.sh <ping_url> [repo_dir]
18
+ #
19
+ # Arguments:
20
+ # ping_url Your HuggingFace Space URL (e.g. https://your-space.hf.space)
21
+ # repo_dir Path to your repo (default: current directory)
22
+ #
23
+ # Examples:
24
+ # ./validate-submission.sh https://my-team.hf.space
25
+ # ./validate-submission.sh https://my-team.hf.space ./my-repo
26
  #
 
 
27
 
28
  set -uo pipefail
29
 
30
  DOCKER_BUILD_TIMEOUT=600
 
31
  if [ -t 1 ]; then
32
  RED='\033[0;31m'
33
  GREEN='\033[0;32m'
 
81
  printf "Error: directory '%s' not found\n" "${2:-.}"
82
  exit 1
83
  fi
 
84
  PING_URL="${PING_URL%/}"
85
+ export PING_URL
86
  PASS=0
 
 
 
 
 
 
 
 
 
 
 
 
 
87
 
88
  log() { printf "[%s] %b\n" "$(date -u +%H:%M:%S)" "$*"; }
89
  pass() { log "${GREEN}PASSED${NC} -- $1"; PASS=$((PASS + 1)); }
 
101
  printf "${BOLD}========================================${NC}\n"
102
  log "Repo: $REPO_DIR"
103
  log "Ping URL: $PING_URL"
 
104
  printf "\n"
105
 
106
+ log "${BOLD}Step 1/3: Pinging HF Space${NC} ($PING_URL/reset) ..."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
 
108
  CURL_OUTPUT=$(portable_mktemp "validate-curl")
109
  CLEANUP_FILES+=("$CURL_OUTPUT")
 
116
  elif [ "$HTTP_CODE" = "000" ]; then
117
  fail "HF Space not reachable (connection failed or timed out)"
118
  hint "Check your network connection and that the Space is running."
119
+ hint "Try: curl -s -o /dev/null -w '%%{http_code}' -X POST $PING_URL/reset"
120
+ stop_at "Step 1"
121
  else
122
  fail "HF Space /reset returned HTTP $HTTP_CODE (expected 200)"
123
  hint "Make sure your Space is running and the URL is correct."
124
+ hint "Try opening $PING_URL in your browser first."
125
+ stop_at "Step 1"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  fi
127
 
128
+ log "${BOLD}Step 2/3: Running docker build${NC} ..."
129
 
130
  if ! command -v docker &>/dev/null; then
131
  fail "docker command not found"
132
  hint "Install Docker: https://docs.docker.com/get-docker/"
133
+ stop_at "Step 2"
134
  fi
135
 
136
  if [ -f "$REPO_DIR/Dockerfile" ]; then
 
139
  DOCKER_CONTEXT="$REPO_DIR/server"
140
  else
141
  fail "No Dockerfile found in repo root or server/ directory"
142
+ stop_at "Step 2"
143
  fi
144
 
145
+ log " Found Dockerfile in $DOCKER_CONTEXT"
146
+
147
  BUILD_OK=false
148
  BUILD_OUTPUT=$(run_with_timeout "$DOCKER_BUILD_TIMEOUT" docker build "$DOCKER_CONTEXT" 2>&1) && BUILD_OK=true
149
 
 
152
  else
153
  fail "Docker build failed (timeout=${DOCKER_BUILD_TIMEOUT}s)"
154
  printf "%s\n" "$BUILD_OUTPUT" | tail -20
155
+ stop_at "Step 2"
156
  fi
157
 
158
+ log "${BOLD}Step 3/3: Running openenv validate${NC} ..."
159
 
160
  VALIDATE_OK=false
161
+ if command -v openenv &>/dev/null; then
162
+ VALIDATE_OUTPUT=$(cd "$REPO_DIR" && openenv validate 2>&1) && VALIDATE_OK=true
163
+ else
164
+ PY_VALIDATE="python3"
165
+ if [ -x "$REPO_DIR/.venv/bin/python" ]; then
166
+ PY_VALIDATE="$REPO_DIR/.venv/bin/python"
167
+ fi
168
+ VALIDATE_OUTPUT=$(cd "$REPO_DIR" && "$PY_VALIDATE" -m openenv.cli.__main__ validate 2>&1) && VALIDATE_OK=true
169
+ fi
170
 
171
  if [ "$VALIDATE_OK" = true ]; then
172
  pass "openenv validate passed"
173
+ [ -n "$VALIDATE_OUTPUT" ] && log " $VALIDATE_OUTPUT"
174
  else
175
  fail "openenv validate failed"
176
  printf "%s\n" "$VALIDATE_OUTPUT"
177
+ stop_at "Step 3"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
  fi
179
 
180
  printf "\n"
181
  printf "${BOLD}========================================${NC}\n"
182
+ printf "${GREEN}${BOLD} All 3/3 checks passed!${NC}\n"
183
  printf "${GREEN}${BOLD} Your submission is ready to submit.${NC}\n"
184
  printf "${BOLD}========================================${NC}\n"
185
  printf "\n"