Spaces:
Sleeping
Sleeping
| """ | |
| app.py β OpenEnv Split-Brain Collapse Environment | |
| ================================================== | |
| Serves: | |
| β’ FastAPI HTTP API β /reset, /step, /state, /health, /tasks | |
| β’ Agent endpoints β /agents, /agent/step | |
| β’ Static HTML UI β GET / β static/index.html | |
| All on port 7860 for Hugging Face Spaces. | |
| ARCHITECTURE: | |
| agents/__init__.py β AGENT_REGISTRY (central config) | |
| agents/<name>/ β each agent's env_class + task metadata | |
| Add a new agent = new folder + one import + one dict entry | |
| """ | |
| import os | |
| from dotenv import load_dotenv | |
| from typing import Optional | |
| from fastapi import FastAPI, HTTPException, Request | |
| from fastapi.responses import JSONResponse | |
| from fastapi.staticfiles import StaticFiles | |
| from pydantic import BaseModel, Field | |
| import uvicorn | |
| from openai import OpenAI | |
| from agents import AGENT_REGISTRY | |
| from agents.split_brain.models import SplitBrainAction | |
| load_dotenv() | |
| # ββ LLM Config ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| API_BASE_URL = os.getenv("API_BASE_URL", "https://router.huggingface.co/v1") | |
| MODEL_NAME = os.getenv("MODEL_NAME", "deepseek-ai/DeepSeek-R1-Distill-Llama-70B") | |
| SPLIT_BRAIN_MODEL = os.getenv("SPLIT_BRAIN_MODEL", "") # GRPO fine-tuned LoRA model | |
| API_KEY = os.getenv("HF_TOKEN", "").strip().strip('"').strip("'") | |
| if API_KEY: | |
| llm_client = OpenAI( | |
| base_url=API_BASE_URL, | |
| api_key=API_KEY, | |
| timeout=300.0, | |
| ) | |
| print(f"[INFO] LLM ready. Token: {API_KEY[:6]}... Model: {MODEL_NAME}") | |
| else: | |
| llm_client = None | |
| print("[WARN] HF_TOKEN not set β LLM calls will fail.") | |
| # ββ Active Environment State βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| active_agent_id = None | |
| active_env = None | |
| def get_or_create_env(agent_id: str): | |
| """Lazily create/switch the active environment when agent changes.""" | |
| global active_agent_id, active_env | |
| agent = AGENT_REGISTRY.get(agent_id) | |
| if not agent: | |
| raise HTTPException( | |
| status_code=400, | |
| detail=f"Unknown agent '{agent_id}'. Available: {list(AGENT_REGISTRY.keys())}", | |
| ) | |
| if active_agent_id != agent_id: | |
| active_agent_id = agent_id | |
| active_env = agent["env_class"]() | |
| return active_env | |
| # ββ FastAPI App βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| app = FastAPI( | |
| title="OpenEnv: Split-Brain Collapse", | |
| description=( | |
| "An OpenEnv-compliant multi-agent RL environment simulating a " | |
| "three-datacenter split-brain network partition crisis. " | |
| "AI agents acting as orchestrator, netops, and dba must collaboratively " | |
| "resolve network partitions, replication storms, and cascading deadlocks." | |
| ), | |
| version="1.0.0", | |
| ) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # CORE RL API (openenv validate + external agents) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def health(): | |
| """Health check β returns 200 OK.""" | |
| return { | |
| "status": "ok", | |
| "env": "split-brain", | |
| "version": "1.0.0", | |
| "llm_configured": bool(API_KEY), | |
| "model": MODEL_NAME, | |
| } | |
| async def reset(request: Request): | |
| """ | |
| Reset the environment for a given task. | |
| Body: {"task": "partition_basic", "agent_id": "split_brain"} | |
| """ | |
| try: | |
| body = await request.json() | |
| except Exception: | |
| body = {} | |
| agent_id = body.get("agent_id", "split_brain") | |
| task = body.get("task", "partition_basic") | |
| env = get_or_create_env(agent_id) | |
| agent = AGENT_REGISTRY[agent_id] | |
| valid = [t["id"] for t in agent["tasks"]] | |
| if task not in valid: | |
| raise HTTPException( | |
| status_code=400, | |
| detail=f"Unknown task '{task}'. Valid: {valid}", | |
| ) | |
| obs = env.reset(task=task) | |
| return JSONResponse(content=obs.model_dump()) | |
| async def step(request: Request): | |
| """Execute one raw action in the environment.""" | |
| if active_env is None: | |
| raise HTTPException(status_code=400, detail="No environment loaded. Call /reset first.") | |
| body = await request.json() | |
| action = SplitBrainAction(**body) | |
| result = active_env.step(action) | |
| return JSONResponse(content=result.model_dump()) | |
| def state(): | |
| """Return the current observation without advancing the episode.""" | |
| if active_env is None: | |
| raise HTTPException(status_code=400, detail="No environment loaded. Call /reset first.") | |
| return JSONResponse(content=active_env.state().model_dump()) | |
| def list_tasks(agent_id: str = "split_brain"): | |
| """List all available tasks with difficulty metadata for a specific agent.""" | |
| if agent_id not in AGENT_REGISTRY: | |
| raise HTTPException(status_code=404, detail="Agent not found.") | |
| return {"tasks": AGENT_REGISTRY[agent_id]["tasks"]} | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # AGENT REGISTRY API (frontend discovers agents + tasks) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def list_agents(): | |
| """ | |
| Return all registered agents with their tasks. | |
| The frontend sidebar auto-populates from this endpoint. | |
| Add new agents to agents/__init__.py β no HTML changes needed. | |
| """ | |
| return [ | |
| { | |
| "id": agent["id"], | |
| "name": agent["name"], | |
| "icon": agent["icon"], | |
| "description": agent["description"], | |
| "tasks": agent["tasks"], | |
| } | |
| for agent in AGENT_REGISTRY.values() | |
| ] | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # LLM AGENT STEP (used by the HTML UI's βΆ Agent Step button) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class AgentStepRequest(BaseModel): | |
| agent_id: str = Field("split_brain", description="Which agent (environment) to step") | |
| task: Optional[str] = Field(None, description="Task hint (set via /reset)") | |
| def agent_step(body: AgentStepRequest): | |
| """ | |
| Run ONE LLM-powered step on the active Split-Brain environment. | |
| The environment provides its own multi-agent prompts via get_llm_prompts(). | |
| """ | |
| if not llm_client: | |
| raise HTTPException(status_code=503, detail="HF_TOKEN not configured.") | |
| env = get_or_create_env(body.agent_id) | |
| obs = env.state() | |
| # Check episode completion | |
| health = getattr(obs, "global_health", None) or getattr(obs, "health_score", 0) | |
| if health >= 1.0 or env.step_count >= env.max_steps: | |
| raise HTTPException(status_code=400, detail="Episode complete. Call /reset to start a new one.") | |
| # The split_brain env always exposes get_llm_prompts() for multi-agent routing | |
| system_prompt, user_prompt = env.get_llm_prompts() | |
| # Auto-select model: prefer fine-tuned LoRA model for cascading_deadlock | |
| active_model = MODEL_NAME | |
| if SPLIT_BRAIN_MODEL and getattr(env, "current_task", "") == "cascading_deadlock": | |
| active_model = SPLIT_BRAIN_MODEL | |
| print(f"[INFO] Using fine-tuned model: {active_model}") | |
| try: | |
| completion = llm_client.chat.completions.create( | |
| model=active_model, | |
| messages=[ | |
| {"role": "system", "content": system_prompt}, | |
| {"role": "user", "content": user_prompt}, | |
| ], | |
| temperature=0.1, | |
| max_tokens=400, | |
| ) | |
| raw_text = completion.choices[0].message.content or "" | |
| print(f"[DEBUG LLM ({active_model})] {raw_text}") | |
| except Exception as e: | |
| raise HTTPException(status_code=502, detail=f"LLM call failed: {str(e)}") | |
| action = env._parse_action(raw_text) | |
| result = env.step(action) | |
| new_obs = result.observation | |
| msg = result.info.get("message", "") | |
| return { | |
| "step": env.step_count, | |
| "agent_id": body.agent_id, | |
| "action": action.model_dump(), | |
| "reward": result.reward, | |
| "done": result.done, | |
| "message": msg, | |
| "observation": new_obs.model_dump(), | |
| "current_actor": result.info.get("current_actor", "orchestrator"), | |
| "delegation_log": result.info.get("delegation_log", []), | |
| } | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # STATIC FILES β serves static/index.html at / | |
| # Mount AFTER all API routes so API routes take priority. | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| app.mount("/", StaticFiles(directory="static", html=True), name="static") | |
| if __name__ == "__main__": | |
| # Change "app:app" (implicit) to "app:fastapi_app" | |
| uvicorn.run(app, host="0.0.0.0", port=7860, log_level="info") |