ChilleD/agent_world_model_env
3
1"""2Stress test simulating large-scale agentic RL with AWM environments.3 4Simulates one RL step: 2000 environments reset in parallel, each runs a5multi-turn episode (random tool calls with LLM-like latency), then closes.6 7Phases per session:8 1. connect + reset — env startup9 2. list_tools — tool discovery10 3. N turns of tool calls — simulate multi-turn agent interaction11 (random tool, empty args, random "thinking" delay between turns)12 4. done + close — episode end13 14Usage:15 # Terminal 1: Start server16 PYTHONPATH=src:envs uv run uvicorn \17 envs.agent_world_model_env.server.app:app \18 --host 0.0.0.0 --port 889919 20 # Terminal 2: Run RL simulation (default 2000 envs)21 PYTHONPATH=src:envs uv run python \22 envs/agent_world_model_env/example_stress_test.py23 24 # Custom scale25 PYTHONPATH=src:envs uv run python \26 envs/agent_world_model_env/example_stress_test.py \27 --scale 500 --concurrency 100 --min-turns 1 --max-turns 328"""29 30import argparse31import asyncio32import json33import logging34import os35import random36import statistics37import sys38import time39from dataclasses import dataclass, field40 41import httpx42import psutil43from openenv.core.env_server.mcp_types import CallToolAction, ListToolsAction44 45from agent_world_model_env import AWMEnv46 47logging.basicConfig(48 level=logging.INFO,49 format="%(asctime)s [%(levelname)s] %(message)s",50 datefmt="%H:%M:%S",51)52log = logging.getLogger("rl_stress")53 54BASE_URL = "http://localhost:8899"55CLIENT_TIMEOUT: float = 600.056 57# Scenarios to cycle through58SCENARIOS = [59 "e_commerce_33",60 "inventory_management_7",61 "document_management_5",62 "billing_payments_3",63 "hris_employee_management_1",64]65 66# RL simulation defaults67MIN_TURNS = 368MAX_TURNS = 2069# Simulate LLM rollout time: uniform [min, max] seconds70LLM_THINK_MIN = 1.071LLM_THINK_MAX = 20.072 73 74# ---------------------------------------------------------------------------75# Data classes76# ---------------------------------------------------------------------------77@dataclass78class SessionResult:79 session_id: int80 scenario: str81 task_idx: int82 num_turns: int # planned turns83 turns_completed: int = 084 connect_s: float = 0.085 reset_s: float = 0.086 list_tools_s: float = 0.087 tool_call_latencies: list[float] = field(default_factory=list)88 done_s: float = 0.089 total_s: float = 0.090 success: bool = False91 error: str | None = None92 num_tools: int = 093 tools_discovered: list[str] = field(default_factory=list)94 95 96# ---------------------------------------------------------------------------97# System resource monitor98# ---------------------------------------------------------------------------99class ResourceMonitor:100 """Periodically samples CPU and memory in a background task."""101 102 def __init__(self, interval: float = 2.0):103 self._interval = interval104 self._samples: list[dict] = []105 self._task: asyncio.Task | None = None106 self._process = psutil.Process(os.getpid())107 self._server_pid: int | None = None108 109 def start(self, server_pid: int | None = None):110 self._server_pid = server_pid111 self._task = asyncio.create_task(self._loop())112 113 async def stop(self):114 if self._task:115 self._task.cancel()116 try:117 await self._task118 except asyncio.CancelledError:119 pass120 121 async def _loop(self):122 while True:123 sample = {124 "time": time.monotonic(),125 "system_cpu_pct": psutil.cpu_percent(interval=0),126 "system_mem_pct": psutil.virtual_memory().percent,127 "system_mem_used_gb": round(128 psutil.virtual_memory().used / (1024**3), 2129 ),130 "client_mem_mb": round(self._process.memory_info().rss / (1024**2), 1),131 }132 if self._server_pid:133 try:134 server_proc = psutil.Process(self._server_pid)135 children = server_proc.children(recursive=True)136 server_mem = server_proc.memory_info().rss137 for child in children:138 try:139 server_mem += child.memory_info().rss140 except (psutil.NoSuchProcess, psutil.AccessDenied):141 pass142 sample["server_tree_mem_mb"] = round(server_mem / (1024**2), 1)143 sample["server_children"] = len(children)144 except (psutil.NoSuchProcess, psutil.AccessDenied):145 pass146 self._samples.append(sample)147 await asyncio.sleep(self._interval)148 149 def summary(self) -> dict:150 if not self._samples:151 return {}152 cpu_vals = [s["system_cpu_pct"] for s in self._samples]153 mem_vals = [s["system_mem_used_gb"] for s in self._samples]154 client_mem = [s["client_mem_mb"] for s in self._samples]155 result = {156 "samples": len(self._samples),157 "cpu_pct": {158 "mean": round(statistics.mean(cpu_vals), 1),159 "max": round(max(cpu_vals), 1),160 },161 "system_mem_gb": {162 "min": round(min(mem_vals), 2),163 "max": round(max(mem_vals), 2),164 },165 "client_mem_mb": {166 "min": round(min(client_mem), 1),167 "max": round(max(client_mem), 1),168 },169 }170 server_mem = [171 s["server_tree_mem_mb"] for s in self._samples if "server_tree_mem_mb" in s172 ]173 if server_mem:174 result["server_tree_mem_mb"] = {175 "min": round(min(server_mem), 1),176 "max": round(max(server_mem), 1),177 }178 server_children = [179 s["server_children"] for s in self._samples if "server_children" in s180 ]181 if server_children:182 result["server_subprocess_peak"] = max(server_children)183 return result184 185 186# ---------------------------------------------------------------------------187# Helpers188# ---------------------------------------------------------------------------189def latency_stats(values: list[float]) -> dict:190 if not values:191 return {}192 s = sorted(values)193 return {194 "count": len(s),195 "min": round(min(s), 3),196 "p50": round(s[len(s) // 2], 3),197 "p90": round(s[int(len(s) * 0.9)], 3),198 "p99": round(s[int(len(s) * 0.99)], 3),199 "max": round(max(s), 3),200 "mean": round(statistics.mean(s), 3),201 }202 203 204async def check_server(url: str) -> int | None:205 """Check server is up, return server PID if available."""206 async with httpx.AsyncClient() as client:207 resp = await client.get(f"{url}/docs", timeout=10)208 resp.raise_for_status()209 # Try to find server PID by matching the port in the URL210 from urllib.parse import urlparse211 212 port = str(urlparse(url).port or "8899")213 for proc in psutil.process_iter(["pid", "cmdline"]):214 try:215 cmdline = " ".join(proc.info["cmdline"] or [])216 if "uvicorn" in cmdline and port in cmdline:217 return proc.info["pid"]218 except (psutil.NoSuchProcess, psutil.AccessDenied):219 pass220 return None221 222 223async def fetch_server_stats(url: str) -> dict | None:224 try:225 async with httpx.AsyncClient() as client:226 resp = await client.get(f"{url}/stats", timeout=5)227 return resp.json()228 except Exception:229 return None230 231 232# ---------------------------------------------------------------------------233# Single session: full RL episode234# ---------------------------------------------------------------------------235@dataclass236class ProgressCounters:237 """Shared counters updated by each session for live progress reporting."""238 239 done: int = 0240 ok: int = 0241 fail: int = 0242 resets_done: int = 0243 turns_done: int = 0244 245 246async def run_rl_episode(247 session_id: int,248 scenario: str,249 task_idx: int,250 num_turns: int,251 reset_semaphore: asyncio.Semaphore,252 interact_semaphore: asyncio.Semaphore,253 counters: ProgressCounters,254) -> SessionResult:255 """Simulate a full RL episode: reset -> list_tools -> N tool calls -> done."""256 r = SessionResult(257 session_id=session_id,258 scenario=scenario,259 task_idx=task_idx,260 num_turns=num_turns,261 )262 session_start = time.monotonic()263 phase = "init"264 env = AWMEnv(265 base_url=BASE_URL, message_timeout_s=CLIENT_TIMEOUT, connect_timeout_s=60.0266 )267 268 try:269 # -- Phase 1: connect + reset (rate-limited to avoid thundering herd) --270 async with reset_semaphore:271 phase = "connect"272 t0 = time.monotonic()273 await env.connect()274 r.connect_s = time.monotonic() - t0275 276 phase = "reset"277 t0 = time.monotonic()278 result = await env.reset(scenario=scenario, task_idx=task_idx)279 r.reset_s = time.monotonic() - t0280 281 if result.observation.reward_type not in ("reset_ok", "reset_warning"):282 r.error = f"reset failed: {result.observation.error}"283 counters.done += 1284 counters.fail += 1285 return r286 287 r.num_tools = result.observation.num_tools or 0288 counters.resets_done += 1289 290 # -- Phase 2: list_tools --291 phase = "list_tools"292 t0 = time.monotonic()293 result = await env.step(ListToolsAction())294 r.list_tools_s = time.monotonic() - t0295 296 # Collect tool names for random calling297 obs = result.observation298 if hasattr(obs, "tools") and obs.tools:299 r.tools_discovered = [300 t.get("name", t.get("tool_name", ""))301 for t in obs.tools302 if isinstance(t, dict)303 ]304 if not r.tools_discovered:305 r.tools_discovered = ["unknown_tool"]306 307 # -- Phase 3: multi-turn tool calling (simulate agent interaction) --308 async with interact_semaphore:309 for turn in range(num_turns):310 phase = f"turn_{turn}"311 312 # Simulate LLM thinking time (async sleep = non-blocking)313 think_time = random.uniform(LLM_THINK_MIN, LLM_THINK_MAX)314 await asyncio.sleep(think_time)315 316 # Pick a random tool and call with empty args (will fail, that's fine)317 tool_name = random.choice(r.tools_discovered)318 t0 = time.monotonic()319 try:320 result = await env.step(321 CallToolAction(tool_name=tool_name, arguments={})322 )323 except Exception:324 # Tool call failure is expected (no args), just measure latency325 pass326 r.tool_call_latencies.append(time.monotonic() - t0)327 r.turns_completed += 1328 counters.turns_done += 1329 330 # -- Phase 4: done + close --331 phase = "done"332 t0 = time.monotonic()333 await env.step(334 CallToolAction(tool_name="done", arguments={"keep_session": False})335 )336 r.done_s = time.monotonic() - t0337 338 r.success = True339 counters.ok += 1340 341 except Exception as e:342 r.error = f"[{phase}] {type(e).__name__}: {str(e)[:200]}"343 counters.fail += 1344 finally:345 r.total_s = time.monotonic() - session_start346 counters.done += 1347 try:348 await env.close()349 except Exception:350 pass351 352 return r353 354 355# ---------------------------------------------------------------------------356# Progress reporter357# ---------------------------------------------------------------------------358async def progress_reporter(359 counters: ProgressCounters,360 total: int,361 total_turns: int,362 monitor: ResourceMonitor,363 interval: float = 10.0,364):365 """Periodically log progress while the test runs."""366 start = time.monotonic()367 while True:368 await asyncio.sleep(interval)369 elapsed = time.monotonic() - start370 in_flight = total - counters.done371 372 stats = await fetch_server_stats(BASE_URL)373 server_sessions = stats.get("total_sessions", "?") if stats else "?"374 375 # Current resource snapshot376 samples = monitor._samples377 last = samples[-1] if samples else {}378 cpu = last.get("system_cpu_pct", "?")379 mem = last.get("system_mem_used_gb", "?")380 server_mem = last.get("server_tree_mem_mb", "?")381 children = last.get("server_children", "?")382 383 log.info(384 f"[{elapsed:.0f}s] episodes={counters.done}/{total} "385 f"ok={counters.ok} fail={counters.fail} "386 f"resets={counters.resets_done} "387 f"turns={counters.turns_done}/{total_turns} "388 f"in_flight={in_flight} | "389 f"server={server_sessions} subprocs={children} | "390 f"cpu={cpu}% mem={mem}GB server={server_mem}MB"391 )392 393 394# ---------------------------------------------------------------------------395# Main test396# ---------------------------------------------------------------------------397async def run_rl_step(398 scale: int,399 concurrency: int,400 min_turns: int,401 max_turns: int,402) -> tuple[list[SessionResult], dict]:403 """Run one RL step: launch `scale` episodes in parallel."""404 405 log.info("=" * 78)406 log.info(407 f"RL STEP SIMULATION: {scale} envs, concurrency={concurrency}, "408 f"turns={min_turns}-{max_turns}, timeout={CLIENT_TIMEOUT}s"409 )410 log.info("=" * 78)411 412 # Discover server PID for resource monitoring413 server_pid = await check_server(BASE_URL)414 log.info(f"Server reachable (pid={server_pid})")415 416 monitor = ResourceMonitor(interval=2.0)417 monitor.start(server_pid)418 419 # Two semaphores:420 # - reset_semaphore: limits concurrent resets (heavy: subprocess spawn)421 # - interact_semaphore: limits concurrent multi-turn interaction422 reset_semaphore = asyncio.Semaphore(concurrency)423 interact_semaphore = asyncio.Semaphore(scale) # no limit on interaction424 425 # Pre-assign turns per session426 turn_counts = [random.randint(min_turns, max_turns) for _ in range(scale)]427 total_planned_turns = sum(turn_counts)428 429 counters = ProgressCounters()430 431 # Launch progress reporter432 progress_task = asyncio.create_task(433 progress_reporter(counters, scale, total_planned_turns, monitor)434 )435 436 wall_start = time.monotonic()437 438 tasks = []439 for i in range(scale):440 scenario = SCENARIOS[i % len(SCENARIOS)]441 task_idx = i % 10442 tasks.append(443 run_rl_episode(444 session_id=i,445 scenario=scenario,446 task_idx=task_idx,447 num_turns=turn_counts[i],448 reset_semaphore=reset_semaphore,449 interact_semaphore=interact_semaphore,450 counters=counters,451 )452 )453 454 completed = await asyncio.gather(*tasks)455 wall_s = time.monotonic() - wall_start456 457 progress_task.cancel()458 try:459 await progress_task460 except asyncio.CancelledError:461 pass462 await monitor.stop()463 464 resource_summary = monitor.summary()465 466 # --------------- Report ---------------467 ok = [r for r in completed if r.success]468 failed = [r for r in completed if not r.success]469 total_turns = sum(r.turns_completed for r in completed)470 total_planned = sum(r.num_turns for r in completed)471 472 log.info("")473 log.info(f"{'=' * 78}")474 log.info(475 f"RESULTS: {len(ok)}/{scale} succeeded, {len(failed)} failed, wall={wall_s:.1f}s"476 )477 log.info(f"Total turns: {total_turns}/{total_planned} completed")478 log.info(f"{'=' * 78}")479 480 # Latency distributions481 for label, values in [482 ("connect", [r.connect_s for r in ok]),483 ("reset", [r.reset_s for r in ok]),484 ("list_tools", [r.list_tools_s for r in ok]),485 ("tool_call", [lat for r in ok for lat in r.tool_call_latencies]),486 ("done", [r.done_s for r in ok]),487 ("episode_total", [r.total_s for r in ok]),488 ]:489 stats = latency_stats(values)490 if stats:491 log.info(f" {label:>14s}: {json.dumps(stats)}")492 493 # Resource summary494 log.info("")495 log.info(f" {'RESOURCES':>14s}: {json.dumps(resource_summary)}")496 497 # Turn distribution498 if ok:499 turn_dist = [r.num_turns for r in ok]500 log.info(501 f" {'turns/episode':>14s}: min={min(turn_dist)} max={max(turn_dist)} "502 f"mean={statistics.mean(turn_dist):.1f}"503 )504 505 # Failures506 if failed:507 log.warning("")508 log.warning(f" {len(failed)} failures:")509 for r in failed[:20]:510 log.warning(511 f" session {r.session_id} ({r.scenario}/{r.task_idx}, "512 f"turns={r.turns_completed}/{r.num_turns}): {r.error}"513 )514 if len(failed) > 20:515 log.warning(f" ... and {len(failed) - 20} more")516 517 return list(completed), resource_summary518 519 520# ---------------------------------------------------------------------------521# CLI522# ---------------------------------------------------------------------------523def parse_args():524 p = argparse.ArgumentParser(525 description="AWM stress test — simulates large-scale agentic RL"526 )527 p.add_argument(528 "--scale",529 type=int,530 default=2000,531 help="Number of parallel environments per RL step (default: 2000)",532 )533 p.add_argument(534 "--concurrency",535 type=int,536 default=256,537 help="Max concurrent resets (default: 256)",538 )539 p.add_argument(540 "--min-turns",541 type=int,542 default=3,543 help="Min tool-call turns per episode (default: 3)",544 )545 p.add_argument(546 "--max-turns",547 type=int,548 default=20,549 help="Max tool-call turns per episode (default: 20)",550 )551 p.add_argument(552 "--think-min",553 type=float,554 default=1.0,555 help="Min LLM rollout time per turn in seconds (default: 1.0)",556 )557 p.add_argument(558 "--think-max",559 type=float,560 default=20.0,561 help="Max LLM rollout time per turn in seconds (default: 20.0)",562 )563 p.add_argument(564 "--url",565 default="http://localhost:8899",566 help="Server base URL (default: http://localhost:8899)",567 )568 p.add_argument(569 "--client-timeout",570 type=float,571 default=600.0,572 help="Client message timeout in seconds (default: 600)",573 )574 return p.parse_args()575 576 577async def main():578 args = parse_args()579 global BASE_URL, CLIENT_TIMEOUT, MIN_TURNS, MAX_TURNS, LLM_THINK_MIN, LLM_THINK_MAX580 BASE_URL = args.url581 CLIENT_TIMEOUT = args.client_timeout582 MIN_TURNS = args.min_turns583 MAX_TURNS = args.max_turns584 LLM_THINK_MIN = args.think_min585 LLM_THINK_MAX = args.think_max586 587 log.info(f"AWM RL Stress Test — server: {BASE_URL}")588 try:589 await check_server(BASE_URL)590 except Exception as e:591 log.error(f"Cannot reach server at {BASE_URL}: {e}")592 sys.exit(1)593 594 results, resources = await run_rl_step(595 args.scale, args.concurrency, args.min_turns, args.max_turns596 )597 598 ok = sum(1 for r in results if r.success)599 fail = len(results) - ok600 601 log.info("")602 log.info("=" * 78)603 log.info("FINAL SUMMARY")604 log.info("=" * 78)605 log.info(606 f" scale={args.scale} concurrency={args.concurrency} ok={ok} fail={fail}"607 )608 log.info(609 f" turns_range=[{args.min_turns},{args.max_turns}] "610 f"total_turns={sum(r.turns_completed for r in results)}"611 )612 if resources:613 log.info(f" resources={json.dumps(resources)}")614 615 if fail > 0:616 log.error("SOME EPISODES FAILED")617 sys.exit(1)618 else:619 log.info("ALL EPISODES PASSED")620 621 622if __name__ == "__main__":623 asyncio.run(main())624 