Sandeep Suresh commited on
Commit
dfc56a2
·
1 Parent(s): 0b9509b

feat: Implement wait action and enhance action handling in simulation environment

Browse files
inference.py CHANGED
@@ -42,33 +42,87 @@ STDOUT FORMAT
42
  [END] success=true steps=3 score=1.00 rewards=0.00,0.00,1.00
43
  """
44
 
45
- import asyncio
 
46
  import os
47
  import textwrap
48
- from typing import List, Optional
 
49
 
50
  from openai import OpenAI
51
- from models import CoenvAction
52
- from client import CoEnv
53
- from dotenv import load_dotenv
54
- load_dotenv()
55
- IMAGE_NAME = os.getenv("IMAGE_NAME")
56
- API_KEY = os.getenv("HF_TOKEN") or os.getenv("API_KEY")
57
-
58
- API_BASE_URL = os.getenv("API_BASE_URL") or "https://router.huggingface.co/v1"
59
- MODEL_NAME = os.getenv("MODEL_NAME") or "Qwen/Qwen2.5-72B-Instruct"
60
- ENV_URL = os.getenv("ENV_URL") or "http://localhost:8000"
61
- MAX_STEPS = 8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  TEMPERATURE = 0.7
63
  MAX_TOKENS = 150
64
  SUCCESS_SCORE_THRESHOLD = 0.1 # normalized score in [0, 1]
 
 
 
 
 
 
 
65
 
66
- _MAX_REWARD_PER_STEP = MAX_TOKENS * 0.1
67
- MAX_TOTAL_REWARD = MAX_STEPS * _MAX_REWARD_PER_STEP
 
 
 
 
 
 
 
 
 
 
 
 
68
 
69
  SYSTEM_PROMPT = textwrap.dedent(
70
  """
71
- You are an agent interacting with an Kubernetes-like simulation environment.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  """
73
  ).strip()
74
 
@@ -91,22 +145,150 @@ def log_end(success: bool, steps: int, score: float, rewards: List[float]) -> No
91
  print(f"[END] success={str(success).lower()} steps={steps} score={score:.3f} rewards={rewards_str}", flush=True)
92
 
93
 
94
- def build_user_prompt(step: int, last_echoed: str, last_reward: float, history: List[str]) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  history_block = "\n".join(history[-4:]) if history else "None"
96
  return textwrap.dedent(
97
  f"""
 
98
  Step: {step}
99
- Last echoed message: {last_echoed!r}
100
- Last reward: {last_reward:.2f}
101
  Previous steps:
102
  {history_block}
103
- Send your next message.
104
  """
105
  ).strip()
106
 
107
 
108
- def get_model_message(client: OpenAI, step: int, last_echoed: str, last_reward: float, history: List[str]) -> str:
109
- user_prompt = build_user_prompt(step, last_echoed, last_reward, history)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  try:
111
  completion = client.chat.completions.create(
112
  model=MODEL_NAME,
@@ -119,66 +301,97 @@ def get_model_message(client: OpenAI, step: int, last_echoed: str, last_reward:
119
  stream=False,
120
  )
121
  text = (completion.choices[0].message.content or "").strip()
122
- return text if text else "hello"
 
 
 
123
  except Exception as exc:
124
  print(f"[DEBUG] Model request failed: {exc}", flush=True)
125
- return "hello"
126
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
 
128
- async def main() -> None:
129
- client = OpenAI(base_url=API_BASE_URL, api_key=API_KEY)
130
-
131
- env = await CoEnv.from_docker_image(IMAGE_NAME)
132
-
133
- history: List[str] = []
134
- rewards: List[float] = []
135
- steps_taken = 0
136
- score = 0.0
137
- success = False
138
-
139
- log_start(task=TASK_NAME, env=BENCHMARK, model=MODEL_NAME)
140
-
141
- try:
142
- result = await env.reset() # OpenENV.reset()
143
- last_echoed = result.observation.echoed_message
144
- last_reward = 0.0
145
 
146
- for step in range(1, MAX_STEPS + 1):
147
- if result.done:
148
- break
 
149
 
150
- message = get_model_message(client, step, last_echoed, last_reward, history)
 
151
 
152
- result = await env.step(CoenvAction(message=message))
153
- obs = result.observation
 
154
 
155
- reward = result.reward or 0.0
156
- done = result.done
157
- error = None
158
 
159
- rewards.append(reward)
160
- steps_taken = step
161
- last_echoed = obs.echoed_message
162
- last_reward = reward
163
 
164
- log_step(step=step, action=message, reward=reward, done=done, error=error)
 
165
 
166
- history.append(f"Step {step}: {message!r} -> reward {reward:+.2f}")
167
 
168
- if done:
169
- break
170
 
171
- score = sum(rewards) / MAX_TOTAL_REWARD if MAX_TOTAL_REWARD > 0 else 0.0
172
- score = min(max(score, 0.0), 1.0) # clamp to [0, 1]
173
- success = score >= SUCCESS_SCORE_THRESHOLD
 
 
 
 
 
174
 
175
- finally:
176
- try:
177
- await env.close()
178
- except Exception as e:
179
- print(f"[DEBUG] env.close() error (container cleanup): {e}", flush=True)
180
- log_end(success=success, steps=steps_taken, score=score, rewards=rewards)
181
 
182
 
183
  if __name__ == "__main__":
184
- asyncio.run(main())
 
42
  [END] success=true steps=3 score=1.00 rewards=0.00,0.00,1.00
43
  """
44
 
45
+ import inspect
46
+ import json
47
  import os
48
  import textwrap
49
+ import time
50
+ from typing import Any, Callable, Dict, List, Optional
51
 
52
  from openai import OpenAI
53
+ try:
54
+ from dotenv import load_dotenv
55
+ except ImportError:
56
+ load_dotenv = None
57
+
58
+ try:
59
+ from models import CoenvAction
60
+ from client import CoEnv
61
+ except ImportError:
62
+ from models import CoenvAction
63
+ from client import CoEnv
64
+
65
+ from server.graders.grader_pod_recovery import grade as grade_pod_recovery
66
+ from server.graders.grader_autoscaling import grade as grade_autoscaling
67
+ from server.graders.grader_incident import grade as grade_incident
68
+
69
+ if load_dotenv is not None:
70
+ load_dotenv()
71
+
72
+ LLM_BASE_URL = os.getenv("LLM_BASE_URL", "https://router.huggingface.co/v1")
73
+ ENV_URL = os.getenv("API_BASE_URL", "http://localhost:8000")
74
+ API_DELAY = float(os.getenv("API_DELAY", "0"))
75
+
76
+ MODEL_NAME = os.getenv("MODEL_NAME", "Qwen/Qwen3-8B")
77
+ API_KEY = os.getenv("OPENROUTER_API_KEY") or os.getenv("HF_TOKEN")
78
+
79
+ BENCHMARKS = ["POD_RECOVERY", "AUTOSCALING", "INCIDENT"]
80
+ TASK_NAMES = ["pod_recovery", "autoscaling", "incident"]
81
+
82
  TEMPERATURE = 0.7
83
  MAX_TOKENS = 150
84
  SUCCESS_SCORE_THRESHOLD = 0.1 # normalized score in [0, 1]
85
+ DEFAULT_MAX_STEPS = 15
86
+
87
+ SUCCESS_SCORE_THRESHOLD_BY_TASK: Dict[str, float] = {
88
+ "pod_recovery": 0.9,
89
+ "autoscaling": 0.9,
90
+ "incident": 0.8,
91
+ }
92
 
93
+ MAX_STALL_REPEATS = 4
94
+ REWARD_EPSILON = 1e-9
95
+
96
+ MAX_STEPS_BY_TASK = {
97
+ "pod_recovery": 15,
98
+ "autoscaling": 20,
99
+ "incident": 30,
100
+ }
101
+
102
+ GRADERS: Dict[str, Callable[[Dict[str, Any], int, int], float]] = {
103
+ "pod_recovery": grade_pod_recovery,
104
+ "autoscaling": grade_autoscaling,
105
+ "incident": grade_incident,
106
+ }
107
 
108
  SYSTEM_PROMPT = textwrap.dedent(
109
  """
110
+ You are a Kubernetes incident-response agent.
111
+ Return ONLY valid JSON for one action with this schema:
112
+ {
113
+ "action_type": "scale|delete_pod|patch|rollout_restart|set_hpa|drain_node|describe|wait",
114
+ "deployment": "... optional ...",
115
+ "replicas": 1,
116
+ "pod_name": "...",
117
+ "resource_type": "deployment|pod|node|service|configmap|hpa",
118
+ "name": "...",
119
+ "patch": {},
120
+ "min_replicas": 1,
121
+ "max_replicas": 5,
122
+ "cpu_target_percent": 70,
123
+ "node_name": "..."
124
+ }
125
+ Do not include markdown, prose, or code fences.
126
  """
127
  ).strip()
128
 
 
145
  print(f"[END] success={str(success).lower()} steps={steps} score={score:.3f} rewards={rewards_str}", flush=True)
146
 
147
 
148
+ def _to_dict(obj: Any) -> Dict[str, Any]:
149
+ if hasattr(obj, "model_dump"):
150
+ return obj.model_dump()
151
+ if isinstance(obj, dict):
152
+ return obj
153
+ return vars(obj)
154
+
155
+
156
+ def _observation_summary(observation: Any) -> str:
157
+ obs = _to_dict(observation)
158
+ pods = obs.get("pods", [])
159
+ deployments = obs.get("deployments", [])
160
+ events = obs.get("events", [])
161
+
162
+ pod_status_counts: Dict[str, int] = {}
163
+ for pod in pods:
164
+ status = pod.get("status", "Unknown")
165
+ pod_status_counts[status] = pod_status_counts.get(status, 0) + 1
166
+
167
+ deployment_lines = []
168
+ for dep in deployments:
169
+ deployment_lines.append(
170
+ f"{dep.get('name')}: desired={dep.get('desired_replicas', 0)} available={dep.get('available_replicas', 0)}"
171
+ )
172
+
173
+ recent_events = [
174
+ f"{e.get('type', 'Normal')}/{e.get('reason', '')}: {e.get('message', '')}"
175
+ for e in events[-5:]
176
+ ]
177
+
178
+ return textwrap.dedent(
179
+ f"""
180
+ Objective: {obs.get('objective', '')}
181
+ Step: {obs.get('step', 0)}
182
+ Pod status counts: {pod_status_counts}
183
+ Deployments:
184
+ {chr(10).join(deployment_lines) if deployment_lines else 'None'}
185
+ Recent events:
186
+ {chr(10).join(recent_events) if recent_events else 'None'}
187
+ """
188
+ ).strip()
189
+
190
+
191
+ def build_user_prompt(task_name: str, step: int, observation: Any, history: List[str]) -> str:
192
  history_block = "\n".join(history[-4:]) if history else "None"
193
  return textwrap.dedent(
194
  f"""
195
+ Task: {task_name}
196
  Step: {step}
197
+ Current cluster summary:
198
+ {_observation_summary(observation)}
199
  Previous steps:
200
  {history_block}
201
+ Return one valid next action as pure JSON.
202
  """
203
  ).strip()
204
 
205
 
206
+ def _safe_json_action(text: str) -> Optional[Dict[str, Any]]:
207
+ try:
208
+ return json.loads(text)
209
+ except json.JSONDecodeError:
210
+ start = text.find("{")
211
+ end = text.rfind("}")
212
+ if start != -1 and end != -1 and end > start:
213
+ try:
214
+ return json.loads(text[start : end + 1])
215
+ except json.JSONDecodeError:
216
+ return None
217
+ return None
218
+
219
+
220
+ def _heuristic_action(task_name: str, observation: Any) -> Dict[str, Any]:
221
+ obs = _to_dict(observation)
222
+ pods = obs.get("pods", [])
223
+
224
+ if task_name == "pod_recovery":
225
+ crashloop = [p for p in pods if p.get("deployment") == "frontend" and p.get("status") == "CrashLoopBackOff"]
226
+ if crashloop:
227
+ return {"action_type": "rollout_restart", "deployment": "frontend"}
228
+ return {"action_type": "describe", "resource_type": "deployment", "name": "frontend"}
229
+
230
+ if task_name == "autoscaling":
231
+ return {
232
+ "action_type": "set_hpa",
233
+ "deployment": "backend",
234
+ "min_replicas": 2,
235
+ "max_replicas": 6,
236
+ "cpu_target_percent": 70,
237
+ }
238
+
239
+ return {"action_type": "rollout_restart", "deployment": "auth-service"}
240
+
241
+
242
+ def _normalize_action(action: Dict[str, Any]) -> Dict[str, Any]:
243
+ action_type = action.get("action_type", "describe")
244
+ if isinstance(action_type, str):
245
+ action_type = {
246
+ "set_hpas": "set_hpa",
247
+ "hpa": "set_hpa",
248
+ "restart_rollout": "rollout_restart",
249
+ "noop": "wait",
250
+ "no_op": "wait",
251
+ "pause": "wait",
252
+ "sleep": "wait",
253
+ }.get(action_type.strip().lower(), action_type.strip().lower())
254
+ else:
255
+ action_type = "describe"
256
+ normalized: Dict[str, Any] = {"action_type": action_type}
257
+
258
+ allowed_fields = {
259
+ "deployment",
260
+ "replicas",
261
+ "pod_name",
262
+ "resource_type",
263
+ "name",
264
+ "patch",
265
+ "min_replicas",
266
+ "max_replicas",
267
+ "cpu_target_percent",
268
+ "node_name",
269
+ }
270
+ for field in allowed_fields:
271
+ if field in action and action[field] is not None:
272
+ normalized[field] = action[field]
273
+
274
+ defaults_by_type = {
275
+ "describe": {"resource_type": "deployment", "name": "frontend"},
276
+ "scale": {"deployment": "frontend", "replicas": 3},
277
+ "rollout_restart": {"deployment": "frontend"},
278
+ "delete_pod": {"pod_name": "frontend-unknown"},
279
+ "drain_node": {"node_name": "node-1"},
280
+ "patch": {"resource_type": "deployment", "name": "frontend", "patch": {}},
281
+ "set_hpa": {"deployment": "backend", "min_replicas": 2, "max_replicas": 6, "cpu_target_percent": 70},
282
+ "wait": {},
283
+ }
284
+ for k, v in defaults_by_type.get(action_type, {}).items():
285
+ normalized.setdefault(k, v)
286
+
287
+ return normalized
288
+
289
+
290
+ def get_model_action(client: OpenAI, task_name: str, step: int, observation: Any, history: List[str]) -> Dict[str, Any]:
291
+ user_prompt = build_user_prompt(task_name, step, observation, history)
292
  try:
293
  completion = client.chat.completions.create(
294
  model=MODEL_NAME,
 
301
  stream=False,
302
  )
303
  text = (completion.choices[0].message.content or "").strip()
304
+ parsed = _safe_json_action(text)
305
+ if isinstance(parsed, dict):
306
+ return _normalize_action(parsed)
307
+ return _heuristic_action(task_name, observation)
308
  except Exception as exc:
309
  print(f"[DEBUG] Model request failed: {exc}", flush=True)
310
+ return _heuristic_action(task_name, observation)
311
 
312
+ def _close_env(env: Any) -> None:
313
+ maybe = env.close()
314
+ if inspect.isawaitable(maybe):
315
+ # CoEnv.sync() should provide sync close(), but support awaitables defensively.
316
+ try:
317
+ while True:
318
+ maybe.send(None)
319
+ except StopIteration:
320
+ pass
321
+
322
+
323
+ def main() -> None:
324
+ if not API_KEY:
325
+ raise RuntimeError("Missing HF_TOKEN/API_KEY for OpenAI client.")
326
+ for TASK_NAME, BENCHMARK in zip(TASK_NAMES, BENCHMARKS):
327
+ client = OpenAI(base_url=LLM_BASE_URL, api_key=API_KEY)
328
+ max_steps = MAX_STEPS_BY_TASK.get(TASK_NAME, DEFAULT_MAX_STEPS)
329
+ grader = GRADERS.get(TASK_NAME, grade_pod_recovery)
330
+
331
+ env = CoEnv(base_url=ENV_URL).sync()
332
+
333
+ history: List[str] = []
334
+ rewards: List[float] = []
335
+ steps_taken = 0
336
+ score = 0.0
337
+ success = False
338
+ final_obs: Optional[Any] = None
339
+ episode_done = False
340
+ stalled = False
341
+ last_action_str: Optional[str] = None
342
+ consecutive_same_action = 0
343
+ last_reward: Optional[float] = None
344
+
345
+ log_start(task=TASK_NAME, env=BENCHMARK, model=MODEL_NAME)
346
 
347
+ try:
348
+ result = env.reset(task=TASK_NAME)
349
+ final_obs = result.observation
 
 
 
 
 
 
 
 
 
 
 
 
 
 
350
 
351
+ for step in range(1, max_steps + 1):
352
+ time.sleep(API_DELAY)
353
+ if result.done:
354
+ break
355
 
356
+ action_payload = get_model_action(client, TASK_NAME, step, final_obs, history)
357
+ action = CoenvAction(**action_payload)
358
 
359
+ result = env.step(action)
360
+ obs = result.observation
361
+ final_obs = obs
362
 
363
+ reward = result.reward or 0.0
364
+ done = result.done
365
+ error = (obs.metadata or {}).get("error") if hasattr(obs, "metadata") else None
366
 
367
+ rewards.append(reward)
368
+ steps_taken = step
369
+ episode_done = bool(done)
 
370
 
371
+ action_str = json.dumps(action_payload, separators=(",", ":"))
372
+ log_step(step=step, action=action_str, reward=reward, done=done, error=error)
373
 
374
+ history.append(f"Step {step}: {action_str} -> reward {reward:+.2f}")
375
 
376
+ if done:
377
+ break
378
 
379
+ world_state = _to_dict(final_obs) if final_obs is not None else {}
380
+ score = grader(world_state, steps_taken, max_steps)
381
+ score = min(max(score, 0.0), 1.0)
382
+ success = (
383
+ episode_done
384
+ and not stalled
385
+ and steps_taken > 0
386
+ )
387
 
388
+ finally:
389
+ try:
390
+ _close_env(env)
391
+ except Exception as e:
392
+ print(f"[DEBUG] env.close() error (container cleanup): {e}", flush=True)
393
+ log_end(success=success, steps=steps_taken, score=score, rewards=rewards)
394
 
395
 
396
  if __name__ == "__main__":
397
+ main()
models.py CHANGED
@@ -48,6 +48,7 @@ class CoenvAction(Action):
48
  "set_hpa",
49
  "drain_node",
50
  "describe",
 
51
  ] = Field(..., description="Type of action to execute")
52
 
53
  deployment: Optional[str] = Field(default=None)
 
48
  "set_hpa",
49
  "drain_node",
50
  "describe",
51
+ "wait",
52
  ] = Field(..., description="Type of action to execute")
53
 
54
  deployment: Optional[str] = Field(default=None)
pyproject.toml CHANGED
@@ -31,6 +31,7 @@ dependencies = [
31
  [project.optional-dependencies]
32
  dev = [
33
  "pytest>=8.0.0",
 
34
  "pytest-cov>=4.0.0",
35
  ]
36
 
 
31
  [project.optional-dependencies]
32
  dev = [
33
  "pytest>=8.0.0",
34
+ "pytest-asyncio>=0.23.0",
35
  "pytest-cov>=4.0.0",
36
  ]
37
 
server/actions/__init__.py CHANGED
@@ -5,6 +5,7 @@ from .rollout_action import RolloutRestartAction
5
  from .hpa_action import SetHPAAction
6
  from .drain_action import DrainNodeAction
7
  from .describe_action import DescribeAction
 
8
  from typing import Union, Any, Dict, Literal
9
 
10
  KubeAction = Union[
@@ -14,10 +15,11 @@ KubeAction = Union[
14
  RolloutRestartAction,
15
  SetHPAAction,
16
  DrainNodeAction,
17
- DescribeAction
 
18
  ]
19
 
20
- ActionType = Literal["scale", "patch", "delete_pod", "rollout_restart", "set_hpa", "drain_node", "describe"]
21
 
22
 
23
  def parse_action(data: Dict[str, Any]) -> KubeAction:
@@ -36,6 +38,7 @@ def parse_action(data: Dict[str, Any]) -> KubeAction:
36
  "set_hpa": SetHPAAction,
37
  "drain_node": DrainNodeAction,
38
  "describe": DescribeAction,
 
39
  }
40
 
41
  action_class = action_map.get(action_type)
@@ -53,6 +56,7 @@ __all__ = [
53
  "SetHPAAction",
54
  "DrainNodeAction",
55
  "DescribeAction",
 
56
  "KubeAction",
57
  "parse_action",
58
  ]
 
5
  from .hpa_action import SetHPAAction
6
  from .drain_action import DrainNodeAction
7
  from .describe_action import DescribeAction
8
+ from .wait_action import WaitAction
9
  from typing import Union, Any, Dict, Literal
10
 
11
  KubeAction = Union[
 
15
  RolloutRestartAction,
16
  SetHPAAction,
17
  DrainNodeAction,
18
+ DescribeAction,
19
+ WaitAction,
20
  ]
21
 
22
+ ActionType = Literal["scale", "patch", "delete_pod", "rollout_restart", "set_hpa", "drain_node", "describe", "wait"]
23
 
24
 
25
  def parse_action(data: Dict[str, Any]) -> KubeAction:
 
38
  "set_hpa": SetHPAAction,
39
  "drain_node": DrainNodeAction,
40
  "describe": DescribeAction,
41
+ "wait": WaitAction,
42
  }
43
 
44
  action_class = action_map.get(action_type)
 
56
  "SetHPAAction",
57
  "DrainNodeAction",
58
  "DescribeAction",
59
+ "WaitAction",
60
  "KubeAction",
61
  "parse_action",
62
  ]
server/actions/wait_action.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel
2
+ from typing import Literal
3
+
4
+
5
+ class WaitAction(BaseModel):
6
+ action_type: Literal["wait"] = "wait"
server/coenv_environment.py CHANGED
@@ -596,7 +596,7 @@ class World:
596
  """Reset the world state and optionally inject a failure condition"""
597
  self.reset_to_healthy()
598
  if condition:
599
- condition.inject(self)
600
  return self.get_observation()
601
 
602
  def get_observation(self, objective: str = "Maintain cluster health"):
 
596
  """Reset the world state and optionally inject a failure condition"""
597
  self.reset_to_healthy()
598
  if condition:
599
+ condition.inject()
600
  return self.get_observation()
601
 
602
  def get_observation(self, objective: str = "Maintain cluster health"):
server/executor.py CHANGED
@@ -9,6 +9,7 @@ from server.actions import (
9
  SetHPAAction,
10
  DrainNodeAction,
11
  DescribeAction,
 
12
  )
13
  from server.models import ClusterObservation
14
 
@@ -35,6 +36,8 @@ def execute(action: KubeAction, world) -> ExecutionResult:
35
  return _execute_drain_node(action, world)
36
  elif isinstance(action, DescribeAction):
37
  return _execute_describe(action, world)
 
 
38
  else:
39
  raise ValueError(f"Unknown action type: {type(action)}")
40
 
@@ -113,3 +116,12 @@ def _execute_describe(action: DescribeAction, world) -> ExecutionResult:
113
  tick_advanced=False,
114
  describe_detail=detail
115
  )
 
 
 
 
 
 
 
 
 
 
9
  SetHPAAction,
10
  DrainNodeAction,
11
  DescribeAction,
12
+ WaitAction,
13
  )
14
  from server.models import ClusterObservation
15
 
 
36
  return _execute_drain_node(action, world)
37
  elif isinstance(action, DescribeAction):
38
  return _execute_describe(action, world)
39
+ elif isinstance(action, WaitAction):
40
+ return _execute_wait(world)
41
  else:
42
  raise ValueError(f"Unknown action type: {type(action)}")
43
 
 
116
  tick_advanced=False,
117
  describe_detail=detail
118
  )
119
+
120
+
121
+ def _execute_wait(world) -> ExecutionResult:
122
+ world.tick()
123
+ return ExecutionResult(
124
+ observation=world.get_observation(),
125
+ action_applied="Waited one simulation tick",
126
+ tick_advanced=True,
127
+ )
server/simulation_service.py CHANGED
@@ -6,7 +6,7 @@ and reward/completion logic so server app wiring stays thin.
6
 
7
  from __future__ import annotations
8
 
9
- from typing import Dict, Any
10
  import json
11
  import os
12
  from openenv.core.env_server.interfaces import Environment
@@ -115,29 +115,111 @@ def calculate_reward(world: World, task_id: str) -> float:
115
  return 0.0
116
 
117
 
118
- def check_task_complete(world: World, task_id: str) -> bool:
119
- """Check if task objective is complete."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  if task_id == "pod_recovery":
121
- pods = world.get_pods()
122
- frontend_pods = [p for p in pods if p.deployment == "frontend"]
123
- running = [p for p in frontend_pods if p.status == "Running"]
124
- return len(frontend_pods) > 0 and len(running) == len(frontend_pods)
 
 
125
 
126
  if task_id == "autoscaling":
127
- pods = world.get_pods()
128
- backend_pods = [p for p in pods if p.deployment == "backend"]
129
- running = [p for p in backend_pods if p.status == "Running"]
130
- return len(backend_pods) >= 2 and len(running) >= 2
 
 
 
 
 
 
 
 
 
 
 
 
131
 
132
  if task_id == "incident":
133
- pods = world.get_pods()
134
- key_services = ["auth-service", "api-gateway", "frontend"]
135
- for svc in key_services:
136
- svc_pods = [p for p in pods if p.deployment == svc]
137
- running = [p for p in svc_pods if p.status == "Running"]
138
- if svc_pods and len(running) < len(svc_pods) * 0.8:
139
- return False
140
- return True
 
 
 
 
 
 
 
 
 
 
141
 
142
  return False
143
 
@@ -151,13 +233,32 @@ class CoenvEnvironment(Environment):
151
  self.world = World(self.config, seed=self.config.get("seed"))
152
  self.current_task = "pod_recovery"
153
  self.current_objective = get_objective_for_task(self.current_task)
 
154
 
155
  def reset(self, task: str = "pod_recovery", **_: Any) -> CoenvObservation:
156
  """Reset simulator state for the selected task and return initial observation."""
157
  self.current_task = task
158
  self.current_objective = get_objective_for_task(task)
159
  condition = get_condition_for_task(task, self.world, self.config)
160
- self.world.reset(condition)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
  return self._observation(done=False, reward=0.0, info={"task": task})
162
 
163
  def step(self, action: CoenvAction, **_: Any) -> CoenvObservation:
@@ -208,6 +309,9 @@ class CoenvEnvironment(Environment):
208
  info["described"] = f"{resource_type}/{name}"
209
  info["describe_detail"] = self.world.describe(resource_type, name)
210
 
 
 
 
211
  else:
212
  info["error"] = f"Unknown action type: {action.action_type}"
213
 
@@ -218,12 +322,10 @@ class CoenvEnvironment(Environment):
218
 
219
  reward = calculate_reward(self.world, self.current_task)
220
 
221
- done = False
222
  max_steps = self.config.get("tasks", {}).get(self.current_task, {}).get("max_steps", 15)
223
- if self.world.step_count >= max_steps:
224
- done = True
225
- if check_task_complete(self.world, self.current_task):
226
- done = True
227
 
228
  return self._observation(done=done, reward=reward, info=info)
229
 
@@ -231,7 +333,7 @@ class CoenvEnvironment(Environment):
231
  def state(self) -> CoenvState:
232
  """Return current observation without applying an action."""
233
  reward = calculate_reward(self.world, self.current_task)
234
- done = check_task_complete(self.world, self.current_task)
235
  return CoenvState(
236
  episode_id=self.episode_id,
237
  step_count=self.world.step_count
 
6
 
7
  from __future__ import annotations
8
 
9
+ from typing import Dict, Any, Optional
10
  import json
11
  import os
12
  from openenv.core.env_server.interfaces import Environment
 
115
  return 0.0
116
 
117
 
118
+ def _collect_task_metrics(world: World) -> Dict[str, Any]:
119
+ """Collect state metrics used by completion logic."""
120
+ pods = world.get_pods()
121
+ deployments = world.get_deployments() if hasattr(world, "get_deployments") else []
122
+ hpas = world.get_hpas() if hasattr(world, "get_hpas") else []
123
+
124
+ def _deployment_running_ratio(name: str) -> float:
125
+ dep_pods = [p for p in pods if p.deployment == name]
126
+ if not dep_pods:
127
+ return 0.0
128
+ running = [p for p in dep_pods if p.status == "Running"]
129
+ return len(running) / len(dep_pods)
130
+
131
+ def _deployment_unstable_count(name: str, restart_threshold: int = 5) -> int:
132
+ dep_pods = [p for p in pods if p.deployment == name]
133
+ unstable = [
134
+ p for p in dep_pods
135
+ if p.status != "Running"
136
+ or p.status == "CrashLoopBackOff"
137
+ or getattr(p, "restarts", 0) >= restart_threshold
138
+ ]
139
+ return len(unstable)
140
+
141
+ key_services = ["auth-service", "api-gateway", "frontend"]
142
+ incident_unhealthy_services = 0
143
+ for svc in key_services:
144
+ if _deployment_running_ratio(svc) < 0.8:
145
+ incident_unhealthy_services += 1
146
+
147
+ backend_hpa = next((h for h in hpas if h.name == "backend-hpa"), None)
148
+ backend_hpa_ok = (
149
+ backend_hpa is not None
150
+ and backend_hpa.min_replicas >= 2
151
+ and backend_hpa.max_replicas >= 6
152
+ and backend_hpa.cpu_target_percent <= 70
153
+ )
154
+
155
+ backend_dep = next((d for d in deployments if d.name == "backend"), None)
156
+ backend_available_ratio = 0.0
157
+ if backend_dep is not None and backend_dep.desired_replicas > 0:
158
+ backend_available_ratio = backend_dep.available_replicas / backend_dep.desired_replicas
159
+
160
+ return {
161
+ "frontend_unstable": _deployment_unstable_count("frontend"),
162
+ "frontend_running_ratio": _deployment_running_ratio("frontend"),
163
+ "backend_unstable": _deployment_unstable_count("backend"),
164
+ "backend_running_ratio": _deployment_running_ratio("backend"),
165
+ "backend_hpa_ok": backend_hpa_ok,
166
+ "backend_available_ratio": backend_available_ratio,
167
+ "incident_unhealthy_services": incident_unhealthy_services,
168
+ "incident_key_unstable": sum(_deployment_unstable_count(svc) for svc in key_services),
169
+ }
170
+
171
+
172
+ def check_task_complete(world: World, task_id: str, baseline_metrics: Optional[Dict[str, Any]] = None) -> bool:
173
+ """Check if task objective is complete via observable state recovery."""
174
+ metrics = _collect_task_metrics(world)
175
+ baseline = baseline_metrics or {}
176
+ has_baseline = bool(baseline)
177
+
178
  if task_id == "pod_recovery":
179
+ if not has_baseline:
180
+ return metrics["frontend_unstable"] == 0 and metrics["frontend_running_ratio"] >= 1.0
181
+ had_problem = baseline.get("frontend_unstable", 0) > 0
182
+ recovered = metrics["frontend_unstable"] == 0 and metrics["frontend_running_ratio"] >= 1.0
183
+ improved = metrics["frontend_unstable"] < baseline.get("frontend_unstable", 0)
184
+ return had_problem and recovered and improved
185
 
186
  if task_id == "autoscaling":
187
+ if not has_baseline:
188
+ return (
189
+ metrics["backend_unstable"] == 0
190
+ and metrics["backend_running_ratio"] >= 1.0
191
+ and metrics["backend_available_ratio"] >= 1.0
192
+ and metrics["backend_hpa_ok"]
193
+ )
194
+ had_problem = baseline.get("backend_unstable", 0) > 0
195
+ recovered = (
196
+ metrics["backend_unstable"] == 0
197
+ and metrics["backend_running_ratio"] >= 1.0
198
+ and metrics["backend_available_ratio"] >= 1.0
199
+ )
200
+ improved = metrics["backend_unstable"] < baseline.get("backend_unstable", 0)
201
+ # For autoscaling, both state recovery and effective HPA policy must be visible.
202
+ return had_problem and recovered and improved and metrics["backend_hpa_ok"]
203
 
204
  if task_id == "incident":
205
+ if not has_baseline:
206
+ return (
207
+ metrics["incident_unhealthy_services"] == 0
208
+ and metrics["incident_key_unstable"] == 0
209
+ )
210
+ had_problem = (
211
+ baseline.get("incident_unhealthy_services", 0) > 0
212
+ or baseline.get("incident_key_unstable", 0) > 0
213
+ )
214
+ recovered = (
215
+ metrics["incident_unhealthy_services"] == 0
216
+ and metrics["incident_key_unstable"] == 0
217
+ )
218
+ improved = (
219
+ metrics["incident_unhealthy_services"] < baseline.get("incident_unhealthy_services", 0)
220
+ or metrics["incident_key_unstable"] < baseline.get("incident_key_unstable", 0)
221
+ )
222
+ return had_problem and recovered and improved
223
 
224
  return False
225
 
 
233
  self.world = World(self.config, seed=self.config.get("seed"))
234
  self.current_task = "pod_recovery"
235
  self.current_objective = get_objective_for_task(self.current_task)
236
+ self._baseline_metrics: Dict[str, Any] = {}
237
 
238
  def reset(self, task: str = "pod_recovery", **_: Any) -> CoenvObservation:
239
  """Reset simulator state for the selected task and return initial observation."""
240
  self.current_task = task
241
  self.current_objective = get_objective_for_task(task)
242
  condition = get_condition_for_task(task, self.world, self.config)
243
+
244
+ # Inject deterministic, task-specific failures so episodes don't start
245
+ # in an already-solved state.
246
+ self.world.reset_to_healthy()
247
+ if condition is not None:
248
+ if task == "pod_recovery":
249
+ condition.inject(target_deployment="frontend", failure_rate=0.8)
250
+ elif task == "autoscaling":
251
+ condition.inject(target_deployment="backend", failure_rate=0.8)
252
+ elif task == "incident":
253
+ condition.inject(root_cause_service="auth-service", failure_probability=0.8)
254
+ try:
255
+ from .conditions.crash_loop import CrashLoopCondition
256
+ except ImportError:
257
+ from conditions.crash_loop import CrashLoopCondition
258
+ # Ensure cascading impact reaches key downstream services.
259
+ CrashLoopCondition(self.world, self.config).inject(target_deployment="api-gateway", failure_rate=0.7)
260
+ CrashLoopCondition(self.world, self.config).inject(target_deployment="frontend", failure_rate=0.5)
261
+ self._baseline_metrics = _collect_task_metrics(self.world)
262
  return self._observation(done=False, reward=0.0, info={"task": task})
263
 
264
  def step(self, action: CoenvAction, **_: Any) -> CoenvObservation:
 
309
  info["described"] = f"{resource_type}/{name}"
310
  info["describe_detail"] = self.world.describe(resource_type, name)
311
 
312
+ elif action.action_type == "wait":
313
+ info["waited"] = True
314
+
315
  else:
316
  info["error"] = f"Unknown action type: {action.action_type}"
317
 
 
322
 
323
  reward = calculate_reward(self.world, self.current_task)
324
 
325
+ done = check_task_complete(self.world, self.current_task, self._baseline_metrics)
326
  max_steps = self.config.get("tasks", {}).get(self.current_task, {}).get("max_steps", 15)
327
+ if self.world.step_count >= max_steps and not done:
328
+ info["truncated"] = True
 
 
329
 
330
  return self._observation(done=done, reward=reward, info=info)
331
 
 
333
  def state(self) -> CoenvState:
334
  """Return current observation without applying an action."""
335
  reward = calculate_reward(self.world, self.current_task)
336
+ done = check_task_complete(self.world, self.current_task, self._baseline_metrics)
337
  return CoenvState(
338
  episode_id=self.episode_id,
339
  step_count=self.world.step_count
server/validator.py CHANGED
@@ -8,6 +8,7 @@ from server.actions import (
8
  SetHPAAction,
9
  DrainNodeAction,
10
  DescribeAction,
 
11
  )
12
 
13
 
@@ -26,6 +27,8 @@ def validate(action: KubeAction, world_state: Dict[str, Any]) -> Optional[str]:
26
  return _validate_drain_node(action, world_state)
27
  elif isinstance(action, DescribeAction):
28
  return _validate_describe(action, world_state)
 
 
29
  return None
30
 
31
 
 
8
  SetHPAAction,
9
  DrainNodeAction,
10
  DescribeAction,
11
+ WaitAction,
12
  )
13
 
14
 
 
27
  return _validate_drain_node(action, world_state)
28
  elif isinstance(action, DescribeAction):
29
  return _validate_describe(action, world_state)
30
+ elif isinstance(action, WaitAction):
31
+ return None
32
  return None
33
 
34
 
tests/test_actions.py CHANGED
@@ -8,6 +8,7 @@ from server.actions import (
8
  SetHPAAction,
9
  DrainNodeAction,
10
  DescribeAction,
 
11
  parse_action,
12
  )
13
 
@@ -202,6 +203,11 @@ class TestParseAction:
202
  assert isinstance(action, DescribeAction)
203
  assert action.name == "frontend"
204
 
 
 
 
 
 
205
  def test_parse_unknown_action_type(self):
206
  with pytest.raises(ValueError, match="Unknown action_type"):
207
  parse_action({"action_type": "unknown_action"})
 
8
  SetHPAAction,
9
  DrainNodeAction,
10
  DescribeAction,
11
+ WaitAction,
12
  parse_action,
13
  )
14
 
 
203
  assert isinstance(action, DescribeAction)
204
  assert action.name == "frontend"
205
 
206
+ def test_parse_wait_action(self):
207
+ raw = {"action_type": "wait"}
208
+ action = parse_action(raw)
209
+ assert isinstance(action, WaitAction)
210
+
211
  def test_parse_unknown_action_type(self):
212
  with pytest.raises(ValueError, match="Unknown action_type"):
213
  parse_action({"action_type": "unknown_action"})
tests/test_executor.py CHANGED
@@ -8,6 +8,7 @@ from server.actions import (
8
  SetHPAAction,
9
  DrainNodeAction,
10
  DescribeAction,
 
11
  )
12
  from server.executor import execute
13
  from server.models import ClusterObservation
@@ -172,3 +173,14 @@ class TestExecutorDescribe:
172
 
173
  assert result.describe_detail is not None
174
  assert result.describe_detail["type"] == "deployment"
 
 
 
 
 
 
 
 
 
 
 
 
8
  SetHPAAction,
9
  DrainNodeAction,
10
  DescribeAction,
11
+ WaitAction,
12
  )
13
  from server.executor import execute
14
  from server.models import ClusterObservation
 
173
 
174
  assert result.describe_detail is not None
175
  assert result.describe_detail["type"] == "deployment"
176
+
177
+
178
+ class TestExecutorWait:
179
+ def test_wait_ticks_without_other_world_mutations(self):
180
+ mock_world = MockWorld()
181
+ action = WaitAction(action_type="wait")
182
+ result = execute(action, mock_world)
183
+
184
+ assert mock_world.tick_called is True
185
+ assert result.tick_advanced is True
186
+ assert result.action_applied == "Waited one simulation tick"
tests/test_inference.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from inference import _normalize_action
2
+
3
+
4
+ def test_normalize_action_maps_set_hpas_to_set_hpa():
5
+ action = _normalize_action({"action_type": "set_hpas", "deployment": "backend"})
6
+
7
+ assert action["action_type"] == "set_hpa"
8
+ assert action["deployment"] == "backend"
9
+ assert action["min_replicas"] == 2
10
+ assert action["max_replicas"] == 6
11
+ assert action["cpu_target_percent"] == 70
12
+
13
+
14
+ def test_normalize_action_non_string_type_defaults_to_describe():
15
+ action = _normalize_action({"action_type": ["set_hpa"]})
16
+
17
+ assert action["action_type"] == "describe"
18
+ assert action["resource_type"] == "deployment"
19
+ assert action["name"] == "frontend"
tests/test_simulation_service.py CHANGED
@@ -92,6 +92,9 @@ def test_environment_step_scale_and_describe_paths():
92
  assert "described" in describe_obs.metadata
93
  assert "describe_detail" in describe_obs.metadata
94
 
 
 
 
95
 
96
  def test_environment_step_exception_is_captured_in_metadata(monkeypatch):
97
  env = CoenvEnvironment()
 
92
  assert "described" in describe_obs.metadata
93
  assert "describe_detail" in describe_obs.metadata
94
 
95
+ wait_obs = env.step(CoenvAction(action_type="wait"))
96
+ assert wait_obs.metadata.get("waited") is True
97
+
98
 
99
  def test_environment_step_exception_is_captured_in_metadata(monkeypatch):
100
  env = CoenvEnvironment()