Spaces:
Sleeping
Sleeping
GitHub Actions commited on
Commit Β·
3a6e663
0
Parent(s):
ci: deploy clean snapshot to HF Space
Browse files- .github/workflows/sync-to-hub.yml +39 -0
- .gitignore +5 -0
- Dockerfile +52 -0
- Dockerfile.api +35 -0
- Dockerfile.train +52 -0
- README.md +167 -0
- agents/__init__.py +34 -0
- agents/split_brain/__init__.py +21 -0
- agents/split_brain/environment.py +813 -0
- agents/split_brain/models.py +141 -0
- app.py +250 -0
- inference.py +136 -0
- inference_lora.py +210 -0
- openenv.yaml +196 -0
- pyproject.toml +25 -0
- requirements.txt +25 -0
- server/app.py +16 -0
- static/index.html +1351 -0
- test_parse.py +4 -0
- test_split_brain.py +27 -0
- test_task5.py +47 -0
- train_unsloth_colab.py +662 -0
- uv.lock +0 -0
.github/workflows/sync-to-hub.yml
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Sync to Hugging Face Hub
|
| 2 |
+
on:
|
| 3 |
+
push:
|
| 4 |
+
branches: [main]
|
| 5 |
+
workflow_dispatch:
|
| 6 |
+
|
| 7 |
+
jobs:
|
| 8 |
+
sync:
|
| 9 |
+
runs-on: ubuntu-latest
|
| 10 |
+
env:
|
| 11 |
+
ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION: true
|
| 12 |
+
steps:
|
| 13 |
+
- uses: actions/checkout@v4
|
| 14 |
+
with:
|
| 15 |
+
fetch-depth: 0
|
| 16 |
+
lfs: true
|
| 17 |
+
|
| 18 |
+
# HF Spaces always reads a file named exactly "Dockerfile".
|
| 19 |
+
# We rename Dockerfile.train β Dockerfile before pushing so the
|
| 20 |
+
# training Space picks up the correct GPU image, not the API server.
|
| 21 |
+
- name: Swap Dockerfile for training Space
|
| 22 |
+
run: |
|
| 23 |
+
cp Dockerfile Dockerfile.api # keep original as backup
|
| 24 |
+
cp Dockerfile.train Dockerfile # training image becomes THE Dockerfile
|
| 25 |
+
|
| 26 |
+
- name: Push to Hugging Face Space
|
| 27 |
+
env:
|
| 28 |
+
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
| 29 |
+
run: |
|
| 30 |
+
set -x
|
| 31 |
+
# Delete github's .git history to avoid pushing past large files (like the deleted safetensors)
|
| 32 |
+
rm -rf .git
|
| 33 |
+
git init
|
| 34 |
+
git branch -M main
|
| 35 |
+
git config user.email "actions@github.com"
|
| 36 |
+
git config user.name "GitHub Actions"
|
| 37 |
+
git add .
|
| 38 |
+
git commit -m "ci: deploy clean snapshot to HF Space"
|
| 39 |
+
git push --force https://soonvalley04:$HF_TOKEN@huggingface.co/spaces/soonvalley04/split-brain-training main
|
.gitignore
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.env
|
| 2 |
+
__pycache__/
|
| 3 |
+
validate.sh
|
| 4 |
+
cluster_triage_updated.code-workspace
|
| 5 |
+
.DS_Store
|
Dockerfile
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ββ OpenEnv: Split-Brain Collapse β GPU Training Space ββββββββββββββββββββββββ
|
| 2 |
+
# This Dockerfile runs the GRPO curriculum training script.
|
| 3 |
+
# Deploy as a separate HF Space with GPU hardware (A10G Small recommended).
|
| 4 |
+
#
|
| 5 |
+
# Env variables to set in Space Secrets:
|
| 6 |
+
# HF_TOKEN β your HF write token (to push adapter + plots)
|
| 7 |
+
# HF_USERNAME β your HF username (default: soonvalley04)
|
| 8 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 9 |
+
|
| 10 |
+
FROM python:3.10-slim
|
| 11 |
+
|
| 12 |
+
WORKDIR /app
|
| 13 |
+
|
| 14 |
+
# System dependencies
|
| 15 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 16 |
+
git curl gcc g++ build-essential \
|
| 17 |
+
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
| 18 |
+
|
| 19 |
+
# Install PyTorch from the official CUDA 12.1 wheel index (safest for HF Spaces A10G)
|
| 20 |
+
# We do not pin versions so pip can cleanly resolve PyTorch 2.6.0 + Torchvision 0.21.0
|
| 21 |
+
# Note: Use cu128 or cu130 as cu121 is outdated for Torch 2.10+
|
| 22 |
+
RUN pip install --no-cache-dir --upgrade pip && \
|
| 23 |
+
pip install --no-cache-dir \
|
| 24 |
+
"torch==2.10.0" \
|
| 25 |
+
"torchvision==0.25.0" \
|
| 26 |
+
"torchaudio==2.10.0" \
|
| 27 |
+
--index-url https://download.pytorch.org/whl/cu128
|
| 28 |
+
|
| 29 |
+
# ββ 2. Install Unsloth + Training Stack (Correct Dependencies) ββββββββββββββ
|
| 30 |
+
RUN pip install --no-cache-dir \
|
| 31 |
+
"unsloth @ git+https://github.com/unslothai/unsloth.git" \
|
| 32 |
+
"unsloth_zoo" \
|
| 33 |
+
"trl" \
|
| 34 |
+
"datasets" \
|
| 35 |
+
"matplotlib" \
|
| 36 |
+
"pydantic" \
|
| 37 |
+
"networkx" \
|
| 38 |
+
"python-dotenv" \
|
| 39 |
+
"huggingface_hub" \
|
| 40 |
+
"openai" \
|
| 41 |
+
"bitsandbytes" \
|
| 42 |
+
"xformers" \
|
| 43 |
+
"torchao>=0.16.0"
|
| 44 |
+
|
| 45 |
+
# Copy repo source
|
| 46 |
+
COPY . .
|
| 47 |
+
|
| 48 |
+
# Create output directories
|
| 49 |
+
RUN mkdir -p plots openenv_outputs openenv-split-brain-lora
|
| 50 |
+
|
| 51 |
+
# Run the curriculum training script
|
| 52 |
+
CMD ["python", "train_unsloth_colab.py"]
|
Dockerfile.api
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ββ OpenEnv: Distributed Data Cluster Triage ββββββββββββββββββββββββββββββ
|
| 2 |
+
# Serves:
|
| 3 |
+
# β’ OpenEnv HTTP API: POST /reset, POST /step, GET /state, GET /health
|
| 4 |
+
# β’ Agent API: GET /agents, POST /agent/step
|
| 5 |
+
# β’ HTML/CSS/JS UI: GET / (static/index.html)
|
| 6 |
+
# Port: 7860 (required for Hugging Face Spaces)
|
| 7 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 8 |
+
|
| 9 |
+
FROM python:3.10.14-slim-bookworm
|
| 10 |
+
|
| 11 |
+
WORKDIR /app
|
| 12 |
+
|
| 13 |
+
# Apply latest security patches
|
| 14 |
+
RUN apt-get update && apt-get upgrade -y \
|
| 15 |
+
&& apt-get install -y --no-install-recommends curl \
|
| 16 |
+
&& apt-get clean \
|
| 17 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 18 |
+
|
| 19 |
+
# Install Python dependencies first (Docker cache layer)
|
| 20 |
+
COPY requirements.txt .
|
| 21 |
+
RUN pip install --no-cache-dir --upgrade pip \
|
| 22 |
+
&& pip install --no-cache-dir -r requirements.txt
|
| 23 |
+
|
| 24 |
+
# Copy environment source (includes static/ directory with index.html)
|
| 25 |
+
COPY . .
|
| 26 |
+
|
| 27 |
+
# Health check β confirms the FastAPI server is live
|
| 28 |
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
|
| 29 |
+
CMD curl -f http://localhost:7860/health || exit 1
|
| 30 |
+
|
| 31 |
+
# Expose Hugging Face Spaces port
|
| 32 |
+
EXPOSE 7860
|
| 33 |
+
|
| 34 |
+
# Run the FastAPI server (HTML UI is served via StaticFiles at /)
|
| 35 |
+
CMD ["uvicorn", "app:fastapi_app", "--host", "0.0.0.0", "--port", "7860", "--workers", "1"]
|
Dockerfile.train
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ββ OpenEnv: Split-Brain Collapse β GPU Training Space ββββββββββββββββββββββββ
|
| 2 |
+
# This Dockerfile runs the GRPO curriculum training script.
|
| 3 |
+
# Deploy as a separate HF Space with GPU hardware (A10G Small recommended).
|
| 4 |
+
#
|
| 5 |
+
# Env variables to set in Space Secrets:
|
| 6 |
+
# HF_TOKEN β your HF write token (to push adapter + plots)
|
| 7 |
+
# HF_USERNAME β your HF username (default: soonvalley04)
|
| 8 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 9 |
+
|
| 10 |
+
FROM python:3.10-slim
|
| 11 |
+
|
| 12 |
+
WORKDIR /app
|
| 13 |
+
|
| 14 |
+
# System dependencies
|
| 15 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 16 |
+
git curl gcc g++ build-essential \
|
| 17 |
+
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
| 18 |
+
|
| 19 |
+
# Install PyTorch from the official CUDA 12.1 wheel index (safest for HF Spaces A10G)
|
| 20 |
+
# We do not pin versions so pip can cleanly resolve PyTorch 2.6.0 + Torchvision 0.21.0
|
| 21 |
+
# Note: Use cu128 or cu130 as cu121 is outdated for Torch 2.10+
|
| 22 |
+
RUN pip install --no-cache-dir --upgrade pip && \
|
| 23 |
+
pip install --no-cache-dir \
|
| 24 |
+
"torch==2.10.0" \
|
| 25 |
+
"torchvision==0.25.0" \
|
| 26 |
+
"torchaudio==2.10.0" \
|
| 27 |
+
--index-url https://download.pytorch.org/whl/cu128
|
| 28 |
+
|
| 29 |
+
# ββ 2. Install Unsloth + Training Stack (Correct Dependencies) ββββββββββββββ
|
| 30 |
+
RUN pip install --no-cache-dir \
|
| 31 |
+
"unsloth @ git+https://github.com/unslothai/unsloth.git" \
|
| 32 |
+
"unsloth_zoo" \
|
| 33 |
+
"trl" \
|
| 34 |
+
"datasets" \
|
| 35 |
+
"matplotlib" \
|
| 36 |
+
"pydantic" \
|
| 37 |
+
"networkx" \
|
| 38 |
+
"python-dotenv" \
|
| 39 |
+
"huggingface_hub" \
|
| 40 |
+
"openai" \
|
| 41 |
+
"bitsandbytes" \
|
| 42 |
+
"xformers" \
|
| 43 |
+
"torchao>=0.16.0"
|
| 44 |
+
|
| 45 |
+
# Copy repo source
|
| 46 |
+
COPY . .
|
| 47 |
+
|
| 48 |
+
# Create output directories
|
| 49 |
+
RUN mkdir -p plots openenv_outputs openenv-split-brain-lora
|
| 50 |
+
|
| 51 |
+
# Run the curriculum training script
|
| 52 |
+
CMD ["python", "train_unsloth_colab.py"]
|
README.md
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: OpenEnv Split-Brain Collapse
|
| 3 |
+
emoji: π§
|
| 4 |
+
colorFrom: indigo
|
| 5 |
+
colorTo: red
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
license: mit
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# π§ OpenEnv: Split-Brain Collapse (Three-Datacenter Crisis)
|
| 12 |
+
|
| 13 |
+
## π Overview & Motivation
|
| 14 |
+
Managing a distributed database cluster spanning multiple datacenters is one of the hardest challenges in Site Reliability Engineering. When network partitions occur between datacenters, competing nodes may each believe they are the authoritative primary β a **split-brain** state that leads to data divergence, replication storms, and cascading deadlocks that require precise, ordered multi-step intervention.
|
| 15 |
+
|
| 16 |
+
This OpenEnv simulates a three-datacenter enterprise infrastructure under a cascading split-brain crisis. It is a genuine multi-agent RL scenario. An AI team consisting of an **orchestrator**, a **netops** specialist, and a **dba** specialist must collaborate to restore connectivity, elect a single primary, reconcile diverged write logs, and recover the cluster before data loss becomes permanent.
|
| 17 |
+
|
| 18 |
+
---
|
| 19 |
+
|
| 20 |
+
## βοΈ Architecture: How It Works
|
| 21 |
+
This environment is built as a complete, containerized Reinforcement Learning (RL) ecosystem:
|
| 22 |
+
|
| 23 |
+
1. **The Simulation Engine (`agents/split_brain/environment.py`):** An OpenEnv-compliant multi-agent state machine tracking datacenter health, network link status, replication lag, transaction deadlocks, and enforcing strict logic gates (e.g., you cannot promote a new primary before routing is verified; resolving a deadlock before re-routing re-triggers it).
|
| 24 |
+
2. **The Backend API (`FastAPI`):** Exposes standard programmatic RL endpoints (`/reset`, `/step`, `/state`, `/health`, `/tasks`) allowing external scripts and evaluators to interact with the environment headlessly.
|
| 25 |
+
3. **The Web Dashboard (`static/index.html`):** A dark-mode, multi-panel "SRE Command Center" that auto-discovers agents from `GET /agents` and lets humans interactively step through the simulation watching the LLM make decisions in real-time.
|
| 26 |
+
4. **The Agent (`inference.py` / `OpenAI Client`):** Uses the `deepseek-ai/DeepSeek-R1-Distill-Llama-70B` model via the Hugging Face Serverless API. The environment injects context-aware multi-agent prompts (different system prompts for orchestrator, netops, dba) so the LLM knows which role it is playing each step.
|
| 27 |
+
|
| 28 |
+
---
|
| 29 |
+
|
| 30 |
+
## π§ Observation & Action Spaces
|
| 31 |
+
The environment strictly adheres to the OpenEnv Pydantic specifications.
|
| 32 |
+
|
| 33 |
+
### Observation Space
|
| 34 |
+
The agent receives a `SplitBrainObservation` detailing the full three-datacenter state:
|
| 35 |
+
* **`global_health`**: (Float 0.0 β 1.0) Continuous cluster stability metric across all three DCs.
|
| 36 |
+
* **`current_actor`**: Which sub-agent is active this step (`orchestrator`, `netops`, or `dba`).
|
| 37 |
+
* **`dc1_dc2_connected` / `dc2_dc3_connected`**: Network link status between datacenters.
|
| 38 |
+
* **`network_status`**: Overall network state (`partitioned`, `degraded`, `healthy`).
|
| 39 |
+
* **`replication_lag_ms`**: Database replication lag in milliseconds.
|
| 40 |
+
* **`primary_db`**: Which DC currently holds the authoritative primary database.
|
| 41 |
+
* **`datacenters`**: List of 3 `DatacenterStatus` objects β status, node count, load %.
|
| 42 |
+
* **`recent_alerts`**: System log messages and critical alerts.
|
| 43 |
+
|
| 44 |
+
### Action Space
|
| 45 |
+
The active sub-agent issues a `SplitBrainAction` JSON object:
|
| 46 |
+
* **`action_type`**: Command to execute β `run_diagnostic`, `update_route`, `verify_routing`, `throttle_bandwidth`, `restore_replica`, `promote_primary`, `force_sync`, `resolve_deadlock`, `failover_region`, `delegate`, `noop`.
|
| 47 |
+
* **`target_id`**: Resource to target (e.g. `dc2_router--dc3_router`, `replica_dc2`).
|
| 48 |
+
* **`target_agent`**: Sub-agent to delegate to (`netops` or `dba`) β only for `delegate` actions.
|
| 49 |
+
* **`instruction_payload`**: Nested action passed to the delegated sub-agent.
|
| 50 |
+
|
| 51 |
+
---
|
| 52 |
+
|
| 53 |
+
## π― Task Descriptions & Difficulty
|
| 54 |
+
The environment features a 5-tier difficulty scale with continuous shaped rewards penalising wrong ordering and rewarding correct multi-step sequences.
|
| 55 |
+
|
| 56 |
+
* **π’ BASIC (The Partition):** A single DC1βDC2 link has failed.
|
| 57 |
+
* *Expected Sequence:* `run_diagnostic` β `update_route` β `verify_routing`.
|
| 58 |
+
* **π‘ STORM (Replication Storm):** A thundering-herd burst has saturated all inter-DC links.
|
| 59 |
+
* *Expected Sequence:* `throttle_bandwidth` β `restore_replica` Γ N β `force_sync`.
|
| 60 |
+
* **π΄ SPLIT (The Split-Brain):** Both inter-DC links are down; two competing primaries have diverged.
|
| 61 |
+
* *Expected Sequence:* Restore both links, isolate one primary, `promote_primary`, reconcile all replicas.
|
| 62 |
+
* **π£ DEADLOCK (Cascading Deadlock):** A distributed transaction deadlock has frozen all write paths.
|
| 63 |
+
* *Expected Sequence:* `update_route` β `verify_routing` β `resolve_deadlock` β `force_sync` (strict order).
|
| 64 |
+
* **β οΈ WIPEOUT (Regional Wipeout):** DC3 offline, DC1/DC2 in split-brain. Full regional failover required.
|
| 65 |
+
* *Expected Sequence:* `failover_region` β `promote_primary` β restore all replicas β `verify_routing`.
|
| 66 |
+
|
| 67 |
+
---
|
| 68 |
+
|
| 69 |
+
## π» Setup & Usage Instructions
|
| 70 |
+
|
| 71 |
+
You can run this project in three different ways depending on your needs.
|
| 72 |
+
|
| 73 |
+
### Method 1: Hugging Face Spaces (Interactive Web UI)
|
| 74 |
+
The easiest way to evaluate the environment is via the public Hugging Face Space. The UI acts as a step-by-step RL debugger.
|
| 75 |
+
|
| 76 |
+
1. **Access the Dashboard:** Open the Hugging Face Space URL.
|
| 77 |
+
2. **Select Threat Level:** Use the sidebar to choose a task scenario.
|
| 78 |
+
3. **Initialize the Environment:** Click the **π Reset** button to boot the simulation.
|
| 79 |
+
4. **Deploy the Agent:** Click the **βΆ AGENT STEP** button. The LLM evaluates the current state and makes exactly *one* move, routing to the correct sub-agent (orchestrator / netops / dba).
|
| 80 |
+
5. **Observe the Triage:** Watch the delegation log, step reward, and datacenter health panel update.
|
| 81 |
+
6. **Iterate:** Continue clicking **βΆ AGENT STEP** until the terminal declares `CLUSTER RESTORED`.
|
| 82 |
+
|
| 83 |
+
### Method 2: Local Docker (Production Simulation)
|
| 84 |
+
Run the exact containerized environment that Hugging Face uses, locally on your machine.
|
| 85 |
+
|
| 86 |
+
1. Create a `.env` file in the root directory:
|
| 87 |
+
```env
|
| 88 |
+
HF_TOKEN="your_huggingface_token"
|
| 89 |
+
MODEL_NAME="deepseek-ai/DeepSeek-R1-Distill-Llama-70B"
|
| 90 |
+
API_BASE_URL="https://router.huggingface.co/v1"
|
| 91 |
+
SPLIT_BRAIN_MODEL="" # optional: path to fine-tuned LoRA model
|
| 92 |
+
```
|
| 93 |
+
2. Build the Docker image:
|
| 94 |
+
```bash
|
| 95 |
+
docker build -t split-brain-env .
|
| 96 |
+
```
|
| 97 |
+
3. Run the container:
|
| 98 |
+
```bash
|
| 99 |
+
docker run -p 7860:7860 --env-file .env split-brain-env
|
| 100 |
+
```
|
| 101 |
+
4. Open your browser at: **`http://localhost:7860`**
|
| 102 |
+
|
| 103 |
+
### Method 3: Local Python (For Developers)
|
| 104 |
+
1. Install the required dependencies:
|
| 105 |
+
```bash
|
| 106 |
+
pip install -r requirements.txt
|
| 107 |
+
```
|
| 108 |
+
2. Ensure your `.env` file is configured (same as Docker step 1).
|
| 109 |
+
3. **Run the Interactive Web Dashboard & API:**
|
| 110 |
+
```bash
|
| 111 |
+
python app.py
|
| 112 |
+
```
|
| 113 |
+
*(Access the UI via `http://127.0.0.1:7860`).*
|
| 114 |
+
4. **Run the Automated Terminal Baseline:**
|
| 115 |
+
```bash
|
| 116 |
+
python inference.py
|
| 117 |
+
```
|
| 118 |
+
|
| 119 |
+
---
|
| 120 |
+
|
| 121 |
+
## 𧬠GRPO Fine-Tuning with Unsloth (LoRA Adapter)
|
| 122 |
+
|
| 123 |
+
### The Problem
|
| 124 |
+
When running the **Cascading Deadlock** task (Task 4) with smaller models like Llama 3.1 8B, the agent gets stuck in an infinite `run_diagnostic` loop β it keeps repeating the same diagnostic action instead of progressing to `update_route`, `verify_routing`, and `resolve_deadlock`. Larger models (70B+) handle this correctly but are expensive to run.
|
| 125 |
+
|
| 126 |
+
### The Solution: GRPO Reinforcement Learning
|
| 127 |
+
We used **Group Relative Policy Optimization (GRPO)** via [Unsloth](https://github.com/unslothai/unsloth) + [TRL](https://github.com/huggingface/trl) to fine-tune `Llama-3.2-3B-Instruct` directly against the OpenEnv reward function. The training:
|
| 128 |
+
|
| 129 |
+
1. Feeds the model the exact stuck scenario (post-diagnostic delegation to netops)
|
| 130 |
+
2. Generates multiple candidate actions via sampling
|
| 131 |
+
3. Steps each action through the live `SplitBrainEnv.step()` function
|
| 132 |
+
4. Rewards correct actions (e.g., `update_route`) and **penalises** repeated diagnostics
|
| 133 |
+
5. The model learns to break out of loops and follow multi-step repair sequences
|
| 134 |
+
|
| 135 |
+
### Training Script
|
| 136 |
+
The training script is `train_unsloth_colab.py` β designed to run on **Google Colab** with a free T4 GPU:
|
| 137 |
+
|
| 138 |
+
```bash
|
| 139 |
+
# In Google Colab:
|
| 140 |
+
!git clone https://github.com/Sayantan181222/openenv-cluster-triage-updated.git
|
| 141 |
+
%cd openenv-cluster-triage-updated
|
| 142 |
+
!pip install -r requirements.txt
|
| 143 |
+
!pip install "unsloth[colab] @ git+https://github.com/unslothai/unsloth.git" trl datasets
|
| 144 |
+
!python train_unsloth_colab.py
|
| 145 |
+
```
|
| 146 |
+
|
| 147 |
+
The trained adapter is saved to `openenv-split-brain-lora/` (~93MB LoRA weights).
|
| 148 |
+
|
| 149 |
+
### Using the LoRA Adapter
|
| 150 |
+
Run the fine-tuned model against the Split-Brain environment:
|
| 151 |
+
|
| 152 |
+
```bash
|
| 153 |
+
python inference_lora.py
|
| 154 |
+
```
|
| 155 |
+
|
| 156 |
+
This loads `Llama-3.2-3B-Instruct` + the LoRA adapter and runs a full episode on the `cascading_deadlock` task, printing a before/after comparison.
|
| 157 |
+
|
| 158 |
+
### Results & Improvement
|
| 159 |
+
|
| 160 |
+
| Metric | Base 8B (No LoRA) | 3B + GRPO LoRA |
|
| 161 |
+
|---|---|---|
|
| 162 |
+
| Diagnostic Loops | 10+ (infinite) | β€1 |
|
| 163 |
+
| Reached `update_route` | β Never | β
Yes |
|
| 164 |
+
| Episode Completion | β Timed out | β
Completes |
|
| 165 |
+
| Model Size | 8B parameters | 3B parameters |
|
| 166 |
+
|
| 167 |
+
The fine-tuned **3B model outperforms the base 8B model** by learning environment-specific action sequences through reinforcement learning.
|
agents/__init__.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
agents/__init__.py β Central Agent Registry
|
| 3 |
+
============================================
|
| 4 |
+
|
| 5 |
+
EXTENSIBILITY:
|
| 6 |
+
To add a new agent, create a new package under agents/:
|
| 7 |
+
agents/my_new_agent/__init__.py β define AGENT_META, ENV_CLASS
|
| 8 |
+
Then import and register it in this file.
|
| 9 |
+
|
| 10 |
+
The frontend sidebar auto-discovers all agents from GET /agents.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from agents.split_brain import AGENT_META as sb_meta, ENV_CLASS as sb_env
|
| 14 |
+
|
| 15 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 16 |
+
# AGENT_REGISTRY
|
| 17 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 18 |
+
# Each key maps an agent_id to its metadata + environment class.
|
| 19 |
+
#
|
| 20 |
+
# To add a new agent:
|
| 21 |
+
# 1. Create agents/your_agent/__init__.py with AGENT_META and ENV_CLASS
|
| 22 |
+
# 2. Import them here
|
| 23 |
+
# 3. Add a new entry to this dict
|
| 24 |
+
#
|
| 25 |
+
# The HTML sidebar and all API endpoints will discover it automatically.
|
| 26 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 27 |
+
AGENT_REGISTRY = {
|
| 28 |
+
"split_brain": {
|
| 29 |
+
**sb_meta,
|
| 30 |
+
"env_class": sb_env,
|
| 31 |
+
},
|
| 32 |
+
# ββ Add new agents below ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 33 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 34 |
+
}
|
agents/split_brain/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
agents/split_brain/__init__.py
|
| 3 |
+
Exports the Split-Brain Collapse environment class and task definitions.
|
| 4 |
+
"""
|
| 5 |
+
from agents.split_brain.environment import SplitBrainEnv
|
| 6 |
+
|
| 7 |
+
AGENT_META = {
|
| 8 |
+
"id": "split_brain",
|
| 9 |
+
"name": "Split-Brain Collapse",
|
| 10 |
+
"icon": "π§ ",
|
| 11 |
+
"description": "Multi-agent DC partition recovery β network, storage & database crisis.",
|
| 12 |
+
"tasks": [
|
| 13 |
+
{"id": "partition_basic", "name": "The Partition", "difficulty": 1, "max_steps": 15, "label": "BASIC", "color": "#10b981"},
|
| 14 |
+
{"id": "replication_storm", "name": "Replication Storm", "difficulty": 2, "max_steps": 25, "label": "STORM", "color": "#fbbf24"},
|
| 15 |
+
{"id": "split_brain", "name": "The Split-Brain", "difficulty": 3, "max_steps": 35, "label": "SPLIT", "color": "#ef4444"},
|
| 16 |
+
{"id": "cascading_deadlock", "name": "Cascading Deadlock", "difficulty": 4, "max_steps": 35, "label": "DEADLOCK", "color": "#7c3aed"},
|
| 17 |
+
{"id": "regional_wipeout", "name": "Regional Wipeout", "difficulty": 5, "max_steps": 50, "label": "WIPEOUT", "color": "#b91c1c", "label_color": "#ff6b6b"},
|
| 18 |
+
],
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
ENV_CLASS = SplitBrainEnv
|
agents/split_brain/environment.py
ADDED
|
@@ -0,0 +1,813 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Split-Brain Collapse Environment
|
| 3 |
+
=================================
|
| 4 |
+
Multi-agent DC partition recovery simulation with network, storage, and database layers.
|
| 5 |
+
3 agents (Orchestrator, NetOps, DataOps) must cooperate via delegation to restore health.
|
| 6 |
+
"""
|
| 7 |
+
import copy, json, re, random
|
| 8 |
+
from typing import Optional, Tuple, List, Dict, Any
|
| 9 |
+
|
| 10 |
+
try:
|
| 11 |
+
import networkx as nx
|
| 12 |
+
except ImportError:
|
| 13 |
+
nx = None
|
| 14 |
+
|
| 15 |
+
from agents.split_brain.models import (
|
| 16 |
+
NetworkNode, NetworkEdge, HDFSCluster, DatabaseState, LedgerEntry,
|
| 17 |
+
SplitBrainObservation, SplitBrainAction, SplitBrainStepResult,
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
# Valid actions per actor
|
| 21 |
+
ACTOR_ACTIONS = {
|
| 22 |
+
"orchestrator": {"delegate", "assess_situation", "noop", "run_diagnostic"},
|
| 23 |
+
"netops": {"update_route", "verify_routing", "throttle_bandwidth", "delegate", "noop", "run_diagnostic"},
|
| 24 |
+
"dataops": {"tune_hdfs", "stop_replication", "force_stepdown", "reconcile_ledger", "clear_cache", "delegate", "noop", "run_diagnostic"},
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
# Role-specific LLM prompts
|
| 28 |
+
SYSTEM_PROMPTS = {
|
| 29 |
+
"orchestrator": (
|
| 30 |
+
"You are the Incident Commander (Orchestrator). You analyze the situation and delegate "
|
| 31 |
+
"tasks to specialist agents. You CANNOT fix things directly β you must delegate.\n"
|
| 32 |
+
"Your only actions: delegate, assess_situation, noop.\n"
|
| 33 |
+
"When delegating, specify target_agent and instruction_payload."
|
| 34 |
+
),
|
| 35 |
+
"netops": (
|
| 36 |
+
"You are the Network Operations specialist. You fix network partitions using routing "
|
| 37 |
+
"and bandwidth management. You understand Dijkstra's algorithm and failover routing.\n"
|
| 38 |
+
"Your actions: update_route, verify_routing, throttle_bandwidth, delegate (back to orchestrator), noop, run_diagnostic.\n"
|
| 39 |
+
"CRITICAL: Once verify_routing succeeds, your network job is DONE. Do NOT try to fix severed links. "
|
| 40 |
+
"Delegate back to orchestrator IMMEDIATELY with a status report.\n"
|
| 41 |
+
"REGIONAL WIPEOUT EXCEPTION: The dc1βdc2 primary link is permanently severed β verify_routing WILL FAIL. "
|
| 42 |
+
"Your job is ONLY to: (1) throttle dc2_router--dc3_router to 10%, (2) establish the oob_tunnel. "
|
| 43 |
+
"Once oob_tunnel_active is True in the state, your work is DONE. Delegate back to orchestrator immediately."
|
| 44 |
+
),
|
| 45 |
+
"dataops": (
|
| 46 |
+
"You are the Data Operations specialist. You manage HDFS storage and NewSQL databases. "
|
| 47 |
+
"You handle replication storms, split-brain resolution, and ledger reconciliation.\n"
|
| 48 |
+
"Your actions: tune_hdfs, stop_replication, force_stepdown, reconcile_ledger, delegate (back to orchestrator), noop.\n"
|
| 49 |
+
"CRITICAL: Do NOT reconcile_ledger unless routing has been verified by NetOps first."
|
| 50 |
+
),
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class SplitBrainEnv:
|
| 55 |
+
def __init__(self):
|
| 56 |
+
self.current_task = None
|
| 57 |
+
self.state_data: Optional[SplitBrainObservation] = None
|
| 58 |
+
self.step_count = 0
|
| 59 |
+
self.max_steps = 35
|
| 60 |
+
# Milestones
|
| 61 |
+
self.bypass_established = False
|
| 62 |
+
self.routing_verified = False
|
| 63 |
+
self.storm_killed = False
|
| 64 |
+
self.stepdown_done = False
|
| 65 |
+
self.ledger_reconciled = False
|
| 66 |
+
# Delayed penalty
|
| 67 |
+
self.corruption_timer = -1
|
| 68 |
+
self.corruption_triggered = False
|
| 69 |
+
# Logs
|
| 70 |
+
self.delegation_log: List[Dict] = []
|
| 71 |
+
|
| 72 |
+
def _reset_trackers(self):
|
| 73 |
+
self.step_count = 0
|
| 74 |
+
self.bypass_established = False
|
| 75 |
+
self.routing_verified = False
|
| 76 |
+
self.storm_killed = False
|
| 77 |
+
self.stepdown_done = False
|
| 78 |
+
self.ledger_reconciled = False
|
| 79 |
+
self.corruption_timer = -1
|
| 80 |
+
self.corruption_triggered = False
|
| 81 |
+
self.delegation_log = []
|
| 82 |
+
|
| 83 |
+
# ββ TOPOLOGY BUILDERS ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 84 |
+
|
| 85 |
+
def _build_nodes(self) -> List[NetworkNode]:
|
| 86 |
+
nodes = []
|
| 87 |
+
dcs = ("dc1", "dc2", "dc3") if self.current_task == "regional_wipeout" else ("dc1", "dc2")
|
| 88 |
+
for dc in dcs:
|
| 89 |
+
nodes.append(NetworkNode(
|
| 90 |
+
node_id=f"{dc}_router", datacenter=dc, node_type="router",
|
| 91 |
+
status="online", cpu_usage=35.0, bandwidth_used=200.0, bandwidth_capacity=1000.0))
|
| 92 |
+
nodes.append(NetworkNode(
|
| 93 |
+
node_id=f"{dc}_switch", datacenter=dc, node_type="switch",
|
| 94 |
+
status="online", cpu_usage=20.0, bandwidth_used=150.0, bandwidth_capacity=1000.0))
|
| 95 |
+
nodes.append(NetworkNode(
|
| 96 |
+
node_id=f"{dc}_server_a", datacenter=dc, node_type="server",
|
| 97 |
+
status="online", cpu_usage=55.0, bandwidth_used=300.0, bandwidth_capacity=1000.0))
|
| 98 |
+
nodes.append(NetworkNode(
|
| 99 |
+
node_id=f"{dc}_server_b", datacenter=dc, node_type="server",
|
| 100 |
+
status="online", cpu_usage=50.0, bandwidth_used=250.0, bandwidth_capacity=1000.0))
|
| 101 |
+
return nodes
|
| 102 |
+
|
| 103 |
+
def _build_edges(self, partitioned=True) -> List[NetworkEdge]:
|
| 104 |
+
internal = [
|
| 105 |
+
("dc1_router", "dc1_switch", 1.0), ("dc1_switch", "dc1_server_a", 0.5),
|
| 106 |
+
("dc1_switch", "dc1_server_b", 0.5), ("dc2_router", "dc2_switch", 1.0),
|
| 107 |
+
("dc2_switch", "dc2_server_a", 0.5), ("dc2_switch", "dc2_server_b", 0.5),
|
| 108 |
+
]
|
| 109 |
+
if self.current_task == "regional_wipeout":
|
| 110 |
+
internal.extend([
|
| 111 |
+
("dc3_router", "dc3_switch", 1.0), ("dc3_switch", "dc3_server_a", 0.5),
|
| 112 |
+
("dc3_switch", "dc3_server_b", 0.5)
|
| 113 |
+
])
|
| 114 |
+
|
| 115 |
+
edges = []
|
| 116 |
+
for src, tgt, lat in internal:
|
| 117 |
+
edges.append(NetworkEdge(
|
| 118 |
+
edge_id=f"{src}--{tgt}", source=src, target=tgt,
|
| 119 |
+
status="healthy", latency_ms=lat, bandwidth_used=150.0, bandwidth_capacity=1000.0))
|
| 120 |
+
|
| 121 |
+
if self.current_task == "regional_wipeout":
|
| 122 |
+
edges.append(NetworkEdge(
|
| 123 |
+
edge_id="dc1_router--dc3_router", source="dc1_router", target="dc3_router",
|
| 124 |
+
status="healthy", latency_ms=10.0, bandwidth_used=0.0, bandwidth_capacity=1000.0))
|
| 125 |
+
edges.append(NetworkEdge(
|
| 126 |
+
edge_id="dc2_router--dc3_router", source="dc2_router", target="dc3_router",
|
| 127 |
+
status="congested", latency_ms=10.0, bandwidth_used=1000.0, bandwidth_capacity=1000.0))
|
| 128 |
+
edges.append(NetworkEdge(
|
| 129 |
+
edge_id="dc1_router--dc2_router", source="dc1_router", target="dc2_router",
|
| 130 |
+
status="severed", latency_ms=5.0, bandwidth_used=0.0, bandwidth_capacity=10000.0))
|
| 131 |
+
else:
|
| 132 |
+
edges.append(NetworkEdge(
|
| 133 |
+
edge_id="dc1_router--dc2_router", source="dc1_router", target="dc2_router",
|
| 134 |
+
status="severed" if partitioned else "healthy",
|
| 135 |
+
latency_ms=5.0, bandwidth_used=0.0, bandwidth_capacity=10000.0))
|
| 136 |
+
edges.append(NetworkEdge(
|
| 137 |
+
edge_id="dc1_router--dc2_switch", source="dc1_router", target="dc2_switch",
|
| 138 |
+
status="congested" if partitioned else "healthy",
|
| 139 |
+
latency_ms=25.0, bandwidth_used=950.0, bandwidth_capacity=1000.0))
|
| 140 |
+
edges.append(NetworkEdge(
|
| 141 |
+
edge_id="dc1_switch--dc2_router", source="dc1_switch", target="dc2_router",
|
| 142 |
+
status="congested" if partitioned else "healthy",
|
| 143 |
+
latency_ms=30.0, bandwidth_used=920.0, bandwidth_capacity=1000.0))
|
| 144 |
+
return edges
|
| 145 |
+
|
| 146 |
+
def _build_ledger(self, conflicts=0) -> List[LedgerEntry]:
|
| 147 |
+
entries = []
|
| 148 |
+
for i in range(min(conflicts, 8)):
|
| 149 |
+
entries.append(LedgerEntry(
|
| 150 |
+
key=f"txn_{1000+i}", dc1_value=f"val_dc1_{random.randint(100,999)}",
|
| 151 |
+
dc2_value=f"val_dc2_{random.randint(100,999)}", conflict=True,
|
| 152 |
+
timestamp_dc1=1700000000.0 + i, timestamp_dc2=1700000000.0 + i + 0.5))
|
| 153 |
+
return entries
|
| 154 |
+
|
| 155 |
+
# ββ RESET ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 156 |
+
|
| 157 |
+
def reset(self, task: str = "partition_basic") -> SplitBrainObservation:
|
| 158 |
+
self.current_task = task
|
| 159 |
+
self._reset_trackers()
|
| 160 |
+
nodes = self._build_nodes()
|
| 161 |
+
edges = self._build_edges(partitioned=True)
|
| 162 |
+
|
| 163 |
+
if task == "partition_basic":
|
| 164 |
+
self.max_steps = 15
|
| 165 |
+
hdfs = HDFSCluster(io_bandwidth_used_pct=20.0, replication_storm_active=False)
|
| 166 |
+
db = DatabaseState(dc1_role="leader", dc2_role="follower", split_brain_active=False)
|
| 167 |
+
alerts = ["Symptom: High latency on DB-West."]
|
| 168 |
+
health = 0.35
|
| 169 |
+
elif task == "replication_storm":
|
| 170 |
+
self.max_steps = 25
|
| 171 |
+
hdfs = HDFSCluster(io_bandwidth_used_pct=100.0, replication_storm_active=True,
|
| 172 |
+
under_replicated_blocks=400)
|
| 173 |
+
db = DatabaseState(dc1_role="leader", dc2_role="follower", split_brain_active=False)
|
| 174 |
+
alerts = ["Symptom: High latency on DB-West.", "Symptom: I/O wait times exceeding thresholds."]
|
| 175 |
+
health = 0.15
|
| 176 |
+
elif task == "split_brain":
|
| 177 |
+
self.max_steps = 35
|
| 178 |
+
hdfs = HDFSCluster(io_bandwidth_used_pct=100.0, replication_storm_active=True,
|
| 179 |
+
under_replicated_blocks=400)
|
| 180 |
+
db = DatabaseState(dc1_role="leader", dc2_role="leader", split_brain_active=True,
|
| 181 |
+
conflicting_entries=15, ledger_sample=self._build_ledger(15))
|
| 182 |
+
alerts = [
|
| 183 |
+
"Symptom: High latency on DB-West.",
|
| 184 |
+
"Symptom: I/O wait times exceeding thresholds.",
|
| 185 |
+
"Symptom: Checkout service returning stale data."
|
| 186 |
+
]
|
| 187 |
+
health = 0.05
|
| 188 |
+
elif task == "cascading_deadlock":
|
| 189 |
+
self.max_steps = 35
|
| 190 |
+
hdfs = HDFSCluster(io_bandwidth_used_pct=20.0, replication_storm_active=False)
|
| 191 |
+
db = DatabaseState(dc1_role="leader", dc2_role="follower", split_brain_active=False)
|
| 192 |
+
alerts = [
|
| 193 |
+
"Symptom: API latency increased from 40ms to 900ms.",
|
| 194 |
+
"Symptom: Database slow query log is growing."
|
| 195 |
+
]
|
| 196 |
+
health = 0.40
|
| 197 |
+
elif task == "regional_wipeout":
|
| 198 |
+
self.max_steps = 50
|
| 199 |
+
hdfs = HDFSCluster(io_bandwidth_used_pct=100.0, replication_storm_active=True,
|
| 200 |
+
under_replicated_blocks=400)
|
| 201 |
+
db = DatabaseState(dc1_role="leader", dc2_role="leader", split_brain_active=True,
|
| 202 |
+
conflicting_entries=15, ledger_sample=self._build_ledger(15))
|
| 203 |
+
alerts = [
|
| 204 |
+
"Symptom: Lost all telemetry from DC-Beta.",
|
| 205 |
+
"Symptom: DC-Gamma is reporting extreme storage I/O alerts.",
|
| 206 |
+
"Symptom: Global transaction ledger is drifting."
|
| 207 |
+
]
|
| 208 |
+
health = 0.05
|
| 209 |
+
else:
|
| 210 |
+
self.max_steps = 15
|
| 211 |
+
hdfs = HDFSCluster(io_bandwidth_used_pct=20.0, replication_storm_active=False)
|
| 212 |
+
db = DatabaseState()
|
| 213 |
+
alerts = ["INFO: Systems nominal."]
|
| 214 |
+
health = 1.0
|
| 215 |
+
|
| 216 |
+
self.state_data = SplitBrainObservation(
|
| 217 |
+
global_health=health, current_actor="orchestrator", step=0, max_steps=self.max_steps,
|
| 218 |
+
network_status="partitioned", dc1_dc2_connected=False, routing_verified=False,
|
| 219 |
+
network_nodes=nodes, network_edges=edges, hdfs=hdfs, newsql=db,
|
| 220 |
+
recent_events=alerts, delegation_context=None,
|
| 221 |
+
auth_status="online",
|
| 222 |
+
redis_cache_usage=55.0 if task == "cascading_deadlock" else 0.0
|
| 223 |
+
)
|
| 224 |
+
return self.state()
|
| 225 |
+
|
| 226 |
+
def state(self) -> SplitBrainObservation:
|
| 227 |
+
return copy.deepcopy(self.state_data)
|
| 228 |
+
|
| 229 |
+
# ββ PATHFINDING ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 230 |
+
|
| 231 |
+
def _build_nx_graph(self):
|
| 232 |
+
if nx is None:
|
| 233 |
+
return None
|
| 234 |
+
G = nx.Graph()
|
| 235 |
+
for n in self.state_data.network_nodes:
|
| 236 |
+
G.add_node(n.node_id)
|
| 237 |
+
for e in self.state_data.network_edges:
|
| 238 |
+
if e.status in ("healthy", "bypass", "congested"):
|
| 239 |
+
avail = max(0.1, e.bandwidth_capacity - e.bandwidth_used)
|
| 240 |
+
w = e.latency_ms + (100.0 / avail)
|
| 241 |
+
G.add_edge(e.source, e.target, weight=w, edge_id=e.edge_id)
|
| 242 |
+
return G
|
| 243 |
+
|
| 244 |
+
def _check_connectivity(self) -> Tuple[bool, Optional[List[str]]]:
|
| 245 |
+
G = self._build_nx_graph()
|
| 246 |
+
if G is None:
|
| 247 |
+
return False, None
|
| 248 |
+
try:
|
| 249 |
+
path = nx.dijkstra_path(G, "dc1_router", "dc2_router", weight="weight")
|
| 250 |
+
return True, path
|
| 251 |
+
except (nx.NetworkXNoPath, nx.NodeNotFound):
|
| 252 |
+
return False, None
|
| 253 |
+
|
| 254 |
+
# ββ ACTION PARSING βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 255 |
+
|
| 256 |
+
def _parse_action(self, action_input) -> SplitBrainAction:
|
| 257 |
+
if isinstance(action_input, SplitBrainAction):
|
| 258 |
+
return action_input
|
| 259 |
+
if isinstance(action_input, dict):
|
| 260 |
+
# Convert nested dict instruction_payloads to strings to avoid type validation errors
|
| 261 |
+
if "instruction_payload" in action_input and isinstance(action_input["instruction_payload"], dict):
|
| 262 |
+
action_input["instruction_payload"] = json.dumps(action_input["instruction_payload"])
|
| 263 |
+
try:
|
| 264 |
+
return SplitBrainAction(**action_input)
|
| 265 |
+
except Exception as e:
|
| 266 |
+
print(f"[DEBUG PARSE ERROR dict] {e}")
|
| 267 |
+
return SplitBrainAction(action_type="noop")
|
| 268 |
+
if isinstance(action_input, str):
|
| 269 |
+
text = re.sub(r'<think>.*?</think>', '', action_input, flags=re.DOTALL).strip()
|
| 270 |
+
text = text.replace("```json", "").replace("```", "").strip()
|
| 271 |
+
|
| 272 |
+
# First try direct JSON parse
|
| 273 |
+
try:
|
| 274 |
+
data = json.loads(text)
|
| 275 |
+
if "instruction_payload" in data and isinstance(data["instruction_payload"], dict):
|
| 276 |
+
data["instruction_payload"] = json.dumps(data["instruction_payload"])
|
| 277 |
+
return SplitBrainAction(**data)
|
| 278 |
+
except Exception as e:
|
| 279 |
+
print(f"[DEBUG PARSE ERROR direct] {e}")
|
| 280 |
+
|
| 281 |
+
# Fallback regex parsing
|
| 282 |
+
match = re.search(r'\{.*\}', text, re.DOTALL)
|
| 283 |
+
if match:
|
| 284 |
+
try:
|
| 285 |
+
data = json.loads(match.group(0))
|
| 286 |
+
if "action_type" in data:
|
| 287 |
+
return SplitBrainAction(**data)
|
| 288 |
+
except Exception:
|
| 289 |
+
pass
|
| 290 |
+
return SplitBrainAction(action_type="noop")
|
| 291 |
+
|
| 292 |
+
# ββ STEP βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 293 |
+
|
| 294 |
+
def step(self, action_input) -> SplitBrainStepResult:
|
| 295 |
+
if self.state_data is None:
|
| 296 |
+
self.reset()
|
| 297 |
+
self.step_count += 1
|
| 298 |
+
self.state_data.step = self.step_count
|
| 299 |
+
action = self._parse_action(action_input)
|
| 300 |
+
reward = 0.0
|
| 301 |
+
done = False
|
| 302 |
+
msg = "No effect."
|
| 303 |
+
actor = self.state_data.current_actor
|
| 304 |
+
|
| 305 |
+
# Validate actor permissions
|
| 306 |
+
valid_actions = ACTOR_ACTIONS.get(actor, set())
|
| 307 |
+
if action.action_type not in valid_actions:
|
| 308 |
+
reward = -0.10
|
| 309 |
+
msg = f"INVALID: '{action.action_type}' not allowed for {actor}. Valid: {sorted(valid_actions)}"
|
| 310 |
+
self._add_event(msg)
|
| 311 |
+
return self._make_result(reward, done, msg)
|
| 312 |
+
|
| 313 |
+
# Dispatch by action type
|
| 314 |
+
if action.action_type == "delegate":
|
| 315 |
+
reward, msg = self._handle_delegate(action)
|
| 316 |
+
elif action.action_type == "assess_situation":
|
| 317 |
+
reward, msg = self._handle_assess()
|
| 318 |
+
elif action.action_type == "update_route":
|
| 319 |
+
reward, msg = self._handle_update_route(action)
|
| 320 |
+
elif action.action_type == "verify_routing":
|
| 321 |
+
reward, msg = self._handle_verify_routing()
|
| 322 |
+
elif action.action_type == "throttle_bandwidth":
|
| 323 |
+
reward, msg = self._handle_throttle(action)
|
| 324 |
+
elif action.action_type == "stop_replication":
|
| 325 |
+
reward, msg = self._handle_stop_replication()
|
| 326 |
+
elif action.action_type == "tune_hdfs":
|
| 327 |
+
reward, msg = self._handle_tune_hdfs(action)
|
| 328 |
+
elif action.action_type == "force_stepdown":
|
| 329 |
+
reward, msg = self._handle_force_stepdown(action)
|
| 330 |
+
elif action.action_type == "reconcile_ledger":
|
| 331 |
+
reward, msg = self._handle_reconcile()
|
| 332 |
+
elif action.action_type == "run_diagnostic":
|
| 333 |
+
reward, msg = self._handle_run_diagnostic()
|
| 334 |
+
elif action.action_type == "clear_cache":
|
| 335 |
+
reward, msg = self._handle_clear_cache()
|
| 336 |
+
elif action.action_type == "noop":
|
| 337 |
+
reward = -0.05
|
| 338 |
+
msg = "WARN: No action taken."
|
| 339 |
+
|
| 340 |
+
# Tick delayed corruption timer
|
| 341 |
+
corruption_penalty = self._tick_corruption()
|
| 342 |
+
reward += corruption_penalty
|
| 343 |
+
|
| 344 |
+
# Task 4: Cascading Deadlock logic
|
| 345 |
+
# Redis only climbs while the root cause (network) is unfixed.
|
| 346 |
+
# Once routing is verified, the app retries stop and Redis stabilizes.
|
| 347 |
+
if self.current_task == "cascading_deadlock":
|
| 348 |
+
if not self.routing_verified and self.state_data.auth_status == "online":
|
| 349 |
+
self.state_data.redis_cache_usage = min(100.0, self.state_data.redis_cache_usage + 20.0)
|
| 350 |
+
|
| 351 |
+
if self.state_data.redis_cache_usage >= 100.0 and self.state_data.auth_status == "online":
|
| 352 |
+
self.state_data.auth_status = "offline"
|
| 353 |
+
interrupt_msg = "CRITICAL: Redis OOM. Auth offline. User session drop rate spiked by 800%."
|
| 354 |
+
self.state_data.recent_events.append(f"[Step {self.step_count}] {interrupt_msg}")
|
| 355 |
+
|
| 356 |
+
if self.state_data.auth_status == "offline":
|
| 357 |
+
reward -= 0.05 # Bleed penalty
|
| 358 |
+
|
| 359 |
+
# Time penalty for all tasks to encourage efficiency
|
| 360 |
+
reward -= 0.02
|
| 361 |
+
|
| 362 |
+
# Update health
|
| 363 |
+
self.state_data.global_health = self._calc_health()
|
| 364 |
+
|
| 365 |
+
# Check win
|
| 366 |
+
if self.state_data.global_health >= 1.0:
|
| 367 |
+
done = True
|
| 368 |
+
msg += " π GLOBAL HEALTH RESTORED TO 1.0 β INCIDENT RESOLVED."
|
| 369 |
+
|
| 370 |
+
# Check max steps
|
| 371 |
+
if not done and self.step_count >= self.max_steps:
|
| 372 |
+
done = True
|
| 373 |
+
msg += f" TIMEOUT: Max steps ({self.max_steps}) reached."
|
| 374 |
+
|
| 375 |
+
self._add_event(msg)
|
| 376 |
+
return self._make_result(reward, done, msg)
|
| 377 |
+
|
| 378 |
+
def _make_result(self, reward, done, msg) -> SplitBrainStepResult:
|
| 379 |
+
return SplitBrainStepResult(
|
| 380 |
+
observation=self.state(), reward=reward, done=done,
|
| 381 |
+
info={"message": msg, "step": self.step_count,
|
| 382 |
+
"current_actor": self.state_data.current_actor,
|
| 383 |
+
"delegation_log": self.delegation_log[-5:]})
|
| 384 |
+
|
| 385 |
+
def _add_event(self, msg: str):
|
| 386 |
+
self.state_data.recent_events.append(f"[Step {self.step_count}] {msg}")
|
| 387 |
+
if len(self.state_data.recent_events) > 20:
|
| 388 |
+
self.state_data.recent_events = self.state_data.recent_events[-20:]
|
| 389 |
+
|
| 390 |
+
# ββ ACTION HANDLERS ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 391 |
+
|
| 392 |
+
def _handle_delegate(self, action: SplitBrainAction) -> Tuple[float, str]:
|
| 393 |
+
target = action.target_agent
|
| 394 |
+
payload = action.instruction_payload or "No specific instructions."
|
| 395 |
+
actor = self.state_data.current_actor
|
| 396 |
+
|
| 397 |
+
if not target or target == actor:
|
| 398 |
+
return -0.05, f"INVALID: Must delegate to a different agent. Got '{target}'."
|
| 399 |
+
|
| 400 |
+
# Check delegation ordering for orchestrator
|
| 401 |
+
reward = 0.05
|
| 402 |
+
if actor == "orchestrator":
|
| 403 |
+
if target == "dataops" and not self.bypass_established and not self.routing_verified:
|
| 404 |
+
reward = -0.10
|
| 405 |
+
msg = f"PENALTY: Delegating to DataOps before network is fixed. Network must come first."
|
| 406 |
+
else:
|
| 407 |
+
msg = f"Orchestrator delegates to {target}: '{payload}'"
|
| 408 |
+
else:
|
| 409 |
+
msg = f"{actor} reports back to {target}: '{payload}'"
|
| 410 |
+
|
| 411 |
+
self.delegation_log.append({
|
| 412 |
+
"step": self.step_count, "from": actor, "to": target, "message": payload})
|
| 413 |
+
self.state_data.current_actor = target
|
| 414 |
+
self.state_data.delegation_context = payload
|
| 415 |
+
return reward, msg
|
| 416 |
+
|
| 417 |
+
def _handle_assess(self) -> Tuple[float, str]:
|
| 418 |
+
issues = []
|
| 419 |
+
if not self.bypass_established:
|
| 420 |
+
issues.append("Network: PRIMARY LINK SEVERED, backup routes congested")
|
| 421 |
+
elif not self.routing_verified:
|
| 422 |
+
issues.append("Network: Bypass active but routing NOT verified")
|
| 423 |
+
else:
|
| 424 |
+
issues.append("Network: HEALTHY β routing verified")
|
| 425 |
+
|
| 426 |
+
if self.state_data.hdfs.replication_storm_active:
|
| 427 |
+
issues.append(f"HDFS: REPLICATION STORM β I/O at {self.state_data.hdfs.io_bandwidth_used_pct:.0f}%")
|
| 428 |
+
else:
|
| 429 |
+
issues.append("HDFS: Stable")
|
| 430 |
+
|
| 431 |
+
if self.state_data.newsql.split_brain_active:
|
| 432 |
+
issues.append(f"NewSQL: SPLIT-BRAIN β {self.state_data.newsql.conflicting_entries} conflicts")
|
| 433 |
+
elif self.stepdown_done and not self.ledger_reconciled:
|
| 434 |
+
issues.append("NewSQL: Stepdown done, ledger needs reconciliation")
|
| 435 |
+
else:
|
| 436 |
+
issues.append("NewSQL: Healthy")
|
| 437 |
+
|
| 438 |
+
if self.current_task == "cascading_deadlock":
|
| 439 |
+
issues.append(f"Redis Cache: {self.state_data.redis_cache_usage:.0f}% used")
|
| 440 |
+
issues.append(f"Auth: {self.state_data.auth_status}")
|
| 441 |
+
|
| 442 |
+
if self.current_task == "regional_wipeout":
|
| 443 |
+
issues.append(f"OOB Tunnel: {'ACTIVE' if self.state_data.oob_tunnel_active else 'NOT ESTABLISHED'}")
|
| 444 |
+
issues.append("Topology: 3-DC (DC-Alpha, DC-Beta, DC-Gamma)")
|
| 445 |
+
|
| 446 |
+
summary = "SITUATION ASSESSMENT:\n" + "\n".join(f" β’ {i}" for i in issues)
|
| 447 |
+
return 0.05, summary
|
| 448 |
+
|
| 449 |
+
def _handle_run_diagnostic(self) -> Tuple[float, str]:
|
| 450 |
+
if random.random() < 0.2:
|
| 451 |
+
return -0.05, "ERROR: Diagnostic tools timeout. Node unresponsive."
|
| 452 |
+
actor = self.state_data.current_actor
|
| 453 |
+
if self.current_task == "cascading_deadlock":
|
| 454 |
+
if actor == "netops":
|
| 455 |
+
return 0.10, "DIAGNOSTIC: dc1_router--dc2_switch is degraded causing packet loss. Use update_route on dc1_router--dc2_switch to establish bypass."
|
| 456 |
+
if actor == "dataops":
|
| 457 |
+
if self.state_data.auth_status == "offline":
|
| 458 |
+
return 0.10, "DIAGNOSTIC: Redis cache OOM (Out of Memory). Requires clear_cache immediately."
|
| 459 |
+
return 0.05, f"DIAGNOSTIC: DB queries slow, retries piling up in Redis cache. (Usage: {self.state_data.redis_cache_usage:.0f}%)"
|
| 460 |
+
return 0.05, "DIAGNOSTIC: Need specialist to check network and data planes."
|
| 461 |
+
elif self.current_task == "regional_wipeout":
|
| 462 |
+
if actor == "netops":
|
| 463 |
+
if not self.state_data.oob_tunnel_active:
|
| 464 |
+
dc2_dc3 = self._find_edge("dc2_router--dc3_router")
|
| 465 |
+
bw = dc2_dc3.bandwidth_used if dc2_dc3 else 1000
|
| 466 |
+
return 0.10, (f"DIAGNOSTIC: CYCLIC DEPENDENCY DETECTED. "
|
| 467 |
+
f"dc1βdc2 link is SEVERED. dc2βdc3 replication storm saturating dc3 at {bw:.0f}Mbps. "
|
| 468 |
+
f"Cannot route through dc3 until storm traffic is reduced. "
|
| 469 |
+
f"SOLUTION: First throttle_bandwidth on dc2_router--dc3_router to 10%, "
|
| 470 |
+
f"then update_route with target_id 'oob_tunnel' to create management link to dc2, "
|
| 471 |
+
f"then delegate to dataops to stop_replication via the tunnel.")
|
| 472 |
+
else:
|
| 473 |
+
return 0.10, "DIAGNOSTIC: OOB tunnel is active. DC-Beta is reachable via management link. Storm can be stopped."
|
| 474 |
+
if actor == "dataops":
|
| 475 |
+
if self.state_data.oob_tunnel_active:
|
| 476 |
+
return 0.10, "DIAGNOSTIC: OOB tunnel active. You can now reach DC-Beta. Use stop_replication to kill the storm."
|
| 477 |
+
return 0.05, "DIAGNOSTIC: Cannot reach DC-Beta. No management path exists. Need NetOps to establish OOB tunnel first."
|
| 478 |
+
return 0.05, "DIAGNOSTIC: 3-DC topology. DC-Alpha(dc1) partitioned from DC-Beta(dc2). DC-Beta flooding DC-Gamma(dc3) with replication storm. You MUST now delegate to NetOps to run_diagnostic and resolve this."
|
| 479 |
+
else:
|
| 480 |
+
return self._handle_assess()
|
| 481 |
+
|
| 482 |
+
def _handle_clear_cache(self) -> Tuple[float, str]:
|
| 483 |
+
if self.current_task == "cascading_deadlock":
|
| 484 |
+
if self.state_data.auth_status == "offline":
|
| 485 |
+
self.state_data.redis_cache_usage = 0.0
|
| 486 |
+
self.state_data.auth_status = "online"
|
| 487 |
+
return 0.95, "Redis cache flushed. Auth services restored."
|
| 488 |
+
else:
|
| 489 |
+
self.state_data.redis_cache_usage = 0.0
|
| 490 |
+
return 0.05, "Redis cache flushed preemptively."
|
| 491 |
+
return 0.0, "INFO: Cache cleared."
|
| 492 |
+
|
| 493 |
+
def _handle_update_route(self, action: SplitBrainAction) -> Tuple[float, str]:
|
| 494 |
+
if random.random() < 0.2:
|
| 495 |
+
return -0.05, "ERROR: SSH Timeout. Node unresponsive."
|
| 496 |
+
eid = action.target_id
|
| 497 |
+
|
| 498 |
+
if self.current_task == "regional_wipeout" and eid == "oob_tunnel":
|
| 499 |
+
if self.state_data.oob_tunnel_active:
|
| 500 |
+
return 0.0, "INFO: OOB tunnel is already active. Delegate to dataops to stop_replication."
|
| 501 |
+
dc2_dc3 = self._find_edge("dc2_router--dc3_router")
|
| 502 |
+
if dc2_dc3 and dc2_dc3.bandwidth_used > 100:
|
| 503 |
+
return -0.05, "FAIL: DC-Gamma routers dropping packets due to 100% bandwidth saturation."
|
| 504 |
+
self.state_data.oob_tunnel_active = True
|
| 505 |
+
self.state_data.dc1_dc2_connected = True
|
| 506 |
+
self.bypass_established = True
|
| 507 |
+
return 0.15, "OOB TUNNEL ESTABLISHED: Low-bandwidth management link to DC-Beta active."
|
| 508 |
+
|
| 509 |
+
edge = self._find_edge(eid)
|
| 510 |
+
if not edge:
|
| 511 |
+
return -0.05, f"WARN: Edge '{eid}' not found. Valid: {self._inter_dc_edge_ids()}"
|
| 512 |
+
|
| 513 |
+
if self.current_task == "regional_wipeout" and eid == "dc2_router--dc3_router":
|
| 514 |
+
if self.state_data.hdfs.replication_storm_active:
|
| 515 |
+
return -0.05, "FAIL: Cannot establish bypass. DC-Gamma is completely saturated by the replication storm."
|
| 516 |
+
|
| 517 |
+
if edge.status == "severed":
|
| 518 |
+
return -0.05, f"FAIL: Edge '{eid}' is physically severed β cannot route through it."
|
| 519 |
+
|
| 520 |
+
if edge.status == "congested":
|
| 521 |
+
edge.status = "bypass"
|
| 522 |
+
edge.bandwidth_used = min(edge.bandwidth_used * 0.4, edge.bandwidth_capacity * 0.5)
|
| 523 |
+
edge.latency_ms = max(edge.latency_ms * 0.6, 2.0)
|
| 524 |
+
if not self.bypass_established:
|
| 525 |
+
self.bypass_established = True
|
| 526 |
+
self.state_data.dc1_dc2_connected = True
|
| 527 |
+
self.state_data.network_status = "degraded"
|
| 528 |
+
return 0.15, f"BYPASS ESTABLISHED on '{eid}'. DC1βDC2 connectivity restored (degraded)."
|
| 529 |
+
return 0.05, f"Additional bypass activated on '{eid}'."
|
| 530 |
+
|
| 531 |
+
if edge.status == "bypass":
|
| 532 |
+
return 0.0, f"INFO: '{eid}' is already in bypass mode."
|
| 533 |
+
|
| 534 |
+
return 0.0, f"INFO: Edge '{eid}' is {edge.status}, no update needed."
|
| 535 |
+
|
| 536 |
+
def _handle_verify_routing(self) -> Tuple[float, str]:
|
| 537 |
+
connected, path = self._check_connectivity()
|
| 538 |
+
if connected and path:
|
| 539 |
+
if not self.routing_verified:
|
| 540 |
+
self.routing_verified = True
|
| 541 |
+
self.state_data.routing_verified = True
|
| 542 |
+
self.state_data.network_status = "healthy" if self.bypass_established else "degraded"
|
| 543 |
+
path_str = " β ".join(path)
|
| 544 |
+
return 0.20, f"ROUTING VERIFIED β Path: {path_str}"
|
| 545 |
+
return 0.0, f"INFO: Routing already verified. Path: {' β '.join(path)}"
|
| 546 |
+
return 0.0, "FAIL: No route from dc1_router to dc2_router. Establish bypass first."
|
| 547 |
+
|
| 548 |
+
def _handle_throttle(self, action: SplitBrainAction) -> Tuple[float, str]:
|
| 549 |
+
eid = action.target_id
|
| 550 |
+
edge = self._find_edge(eid)
|
| 551 |
+
if not edge:
|
| 552 |
+
return -0.05, f"WARN: Edge '{eid}' not found."
|
| 553 |
+
limit = (action.parameters or {}).get("limit_pct", 50)
|
| 554 |
+
|
| 555 |
+
# Regional Wipeout: The OOB tunnel requires bandwidth_used <= 100.
|
| 556 |
+
# bandwidth_used = capacity * (limit/100), so limit must be <= 10%.
|
| 557 |
+
# Give explicit corrective feedback if the LLM uses a bad value.
|
| 558 |
+
if self.current_task == "regional_wipeout" and eid == "dc2_router--dc3_router":
|
| 559 |
+
if limit > 10:
|
| 560 |
+
edge.bandwidth_used = edge.bandwidth_capacity * (limit / 100.0)
|
| 561 |
+
return -0.05, (
|
| 562 |
+
f"Bandwidth on '{eid}' throttled to {limit}%, but this is NOT ENOUGH. "
|
| 563 |
+
f"Current bandwidth: {edge.bandwidth_used:.0f}Mbps. "
|
| 564 |
+
f"OOB tunnel requires bandwidth BELOW 100Mbps. "
|
| 565 |
+
f"You MUST set limit_pct to 10 or lower. "
|
| 566 |
+
f"Use: {{\"action_type\": \"throttle_bandwidth\", \"target_id\": \"{eid}\", \"parameters\": {{\"limit_pct\": 10}}}}"
|
| 567 |
+
)
|
| 568 |
+
|
| 569 |
+
edge.bandwidth_used = edge.bandwidth_capacity * (limit / 100.0)
|
| 570 |
+
return 0.05, f"Bandwidth on '{eid}' throttled to {limit}%."
|
| 571 |
+
|
| 572 |
+
def _handle_stop_replication(self) -> Tuple[float, str]:
|
| 573 |
+
hdfs = self.state_data.hdfs
|
| 574 |
+
if not hdfs.replication_storm_active:
|
| 575 |
+
if not self.routing_verified:
|
| 576 |
+
return 0.0, "INFO: Replication storm already halted. Routing not yet verified β delegate back to orchestrator so NetOps can verify_routing first."
|
| 577 |
+
elif not self.stepdown_done:
|
| 578 |
+
return 0.0, "INFO: Replication storm already halted. Proceed to force_stepdown on dc2."
|
| 579 |
+
elif not self.ledger_reconciled:
|
| 580 |
+
return 0.0, "INFO: Stepdown already complete. Proceed to reconcile_ledger now."
|
| 581 |
+
else:
|
| 582 |
+
return 0.0, "INFO: All DataOps tasks complete. Delegate back to orchestrator."
|
| 583 |
+
|
| 584 |
+
if self.current_task == "regional_wipeout" and not self.state_data.oob_tunnel_active:
|
| 585 |
+
return -0.05, "FAIL: Cannot reach DC-Beta. Primary link severed and no OOB tunnel exists."
|
| 586 |
+
|
| 587 |
+
if random.random() < 0.2:
|
| 588 |
+
return -0.05, "ERROR: SSH Timeout. Node unresponsive."
|
| 589 |
+
|
| 590 |
+
hdfs.replication_storm_active = False
|
| 591 |
+
hdfs.io_bandwidth_used_pct = 20.0
|
| 592 |
+
self.storm_killed = True
|
| 593 |
+
return 0.15, "REPLICATION STORM HALTED β I/O bandwidth released. Next: force_stepdown on dc2."
|
| 594 |
+
|
| 595 |
+
|
| 596 |
+
def _handle_tune_hdfs(self, action: SplitBrainAction) -> Tuple[float, str]:
|
| 597 |
+
hdfs = self.state_data.hdfs
|
| 598 |
+
params = action.parameters or {}
|
| 599 |
+
new_rf = params.get("replication_factor", 2)
|
| 600 |
+
old_rf = hdfs.replication_factor
|
| 601 |
+
hdfs.target_replication_factor = new_rf
|
| 602 |
+
hdfs.replication_factor = new_rf
|
| 603 |
+
if hdfs.replication_storm_active:
|
| 604 |
+
hdfs.io_bandwidth_used_pct = max(20.0, hdfs.io_bandwidth_used_pct * 0.5)
|
| 605 |
+
if hdfs.io_bandwidth_used_pct <= 30:
|
| 606 |
+
hdfs.replication_storm_active = False
|
| 607 |
+
self.storm_killed = True
|
| 608 |
+
hdfs.under_replicated_blocks = max(0, hdfs.under_replicated_blocks - 200)
|
| 609 |
+
return 0.10, f"HDFS tuned: replication {old_rf}β{new_rf}. I/O now at {hdfs.io_bandwidth_used_pct:.0f}%."
|
| 610 |
+
|
| 611 |
+
def _handle_force_stepdown(self, action: SplitBrainAction) -> Tuple[float, str]:
|
| 612 |
+
if random.random() < 0.2:
|
| 613 |
+
return -0.05, "ERROR: SSH Timeout. Node unresponsive."
|
| 614 |
+
db = self.state_data.newsql
|
| 615 |
+
if not db.split_brain_active:
|
| 616 |
+
if self.stepdown_done and not self.ledger_reconciled:
|
| 617 |
+
return 0.0, "INFO: Stepdown already complete. Proceed to reconcile_ledger now."
|
| 618 |
+
return 0.0, "INFO: No split-brain β database is healthy. Delegate back to orchestrator."
|
| 619 |
+
if not self.state_data.dc1_dc2_connected:
|
| 620 |
+
return -0.15, "FAIL: Cannot force stepdown β DCs not connected. Fix network first."
|
| 621 |
+
target_dc = action.target_id or "dc2"
|
| 622 |
+
if target_dc == "dc2":
|
| 623 |
+
db.dc2_role = "follower"
|
| 624 |
+
else:
|
| 625 |
+
db.dc1_role = "follower"
|
| 626 |
+
db.split_brain_active = False
|
| 627 |
+
self.stepdown_done = True
|
| 628 |
+
return 0.15, f"STEPDOWN COMPLETE β {target_dc} demoted to follower. Ledger reconciliation needed."
|
| 629 |
+
|
| 630 |
+
def _handle_reconcile(self) -> Tuple[float, str]:
|
| 631 |
+
db = self.state_data.newsql
|
| 632 |
+
if db.split_brain_active:
|
| 633 |
+
return -0.15, "FAIL: Cannot reconcile β split-brain still active. Force stepdown first."
|
| 634 |
+
if not self.stepdown_done:
|
| 635 |
+
return -0.10, "FAIL: No stepdown performed. Force stepdown first."
|
| 636 |
+
if self.ledger_reconciled:
|
| 637 |
+
return 0.0, "INFO: Ledger already reconciled."
|
| 638 |
+
|
| 639 |
+
self.ledger_reconciled = True
|
| 640 |
+
db.conflicting_entries = 0
|
| 641 |
+
db.ledger_sample = []
|
| 642 |
+
|
| 643 |
+
if not self.routing_verified:
|
| 644 |
+
# Silent trap β start corruption timer
|
| 645 |
+
self.corruption_timer = 10
|
| 646 |
+
return 0.25, "LEDGER RECONCILED (β routing unverified β sync may be unreliable)."
|
| 647 |
+
|
| 648 |
+
return 0.25, "LEDGER RECONCILED β All conflicts resolved via Last-Writer-Wins."
|
| 649 |
+
|
| 650 |
+
# ββ DELAYED CORRUPTION βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 651 |
+
|
| 652 |
+
def _tick_corruption(self) -> float:
|
| 653 |
+
if self.corruption_timer < 0:
|
| 654 |
+
return 0.0
|
| 655 |
+
self.corruption_timer -= 1
|
| 656 |
+
if self.corruption_timer <= 0 and not self.corruption_triggered:
|
| 657 |
+
self.corruption_triggered = True
|
| 658 |
+
self.ledger_reconciled = False
|
| 659 |
+
db = self.state_data.newsql
|
| 660 |
+
db.conflicting_entries = 20
|
| 661 |
+
db.ledger_sample = self._build_ledger(20)
|
| 662 |
+
self._add_event("π DELAYED CORRUPTION: Ledger re-corrupted! Reconciliation failed due to unverified routing.")
|
| 663 |
+
return -0.50
|
| 664 |
+
return 0.0
|
| 665 |
+
|
| 666 |
+
# ββ HEALTH CALCULATION βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 667 |
+
|
| 668 |
+
def _calc_health(self) -> float:
|
| 669 |
+
components = []
|
| 670 |
+
# Network component
|
| 671 |
+
if self.routing_verified:
|
| 672 |
+
components.append(1.0)
|
| 673 |
+
elif self.bypass_established:
|
| 674 |
+
components.append(0.5)
|
| 675 |
+
else:
|
| 676 |
+
components.append(0.0)
|
| 677 |
+
|
| 678 |
+
if self.current_task in ("replication_storm", "split_brain", "regional_wipeout"):
|
| 679 |
+
if self.storm_killed:
|
| 680 |
+
components.append(1.0)
|
| 681 |
+
elif self.state_data.hdfs.io_bandwidth_used_pct < 50:
|
| 682 |
+
components.append(0.5)
|
| 683 |
+
else:
|
| 684 |
+
components.append(0.0)
|
| 685 |
+
|
| 686 |
+
if self.current_task in ("split_brain", "regional_wipeout"):
|
| 687 |
+
if self.ledger_reconciled and not self.corruption_triggered:
|
| 688 |
+
components.append(1.0)
|
| 689 |
+
elif self.stepdown_done:
|
| 690 |
+
components.append(0.4)
|
| 691 |
+
else:
|
| 692 |
+
components.append(0.0)
|
| 693 |
+
|
| 694 |
+
# Task 4: Redis/Auth must be healthy
|
| 695 |
+
if self.current_task == "cascading_deadlock":
|
| 696 |
+
if self.state_data.auth_status == "online" and self.state_data.redis_cache_usage <= 10:
|
| 697 |
+
components.append(1.0)
|
| 698 |
+
elif self.state_data.auth_status == "online":
|
| 699 |
+
components.append(0.5)
|
| 700 |
+
else:
|
| 701 |
+
components.append(0.0)
|
| 702 |
+
|
| 703 |
+
# Task 5: OOB tunnel must be established
|
| 704 |
+
if self.current_task == "regional_wipeout":
|
| 705 |
+
if self.state_data.oob_tunnel_active:
|
| 706 |
+
components.append(1.0)
|
| 707 |
+
else:
|
| 708 |
+
components.append(0.0)
|
| 709 |
+
|
| 710 |
+
return sum(components) / len(components) if components else 1.0
|
| 711 |
+
|
| 712 |
+
# ββ HELPERS ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 713 |
+
|
| 714 |
+
def _find_edge(self, edge_id: Optional[str]) -> Optional[NetworkEdge]:
|
| 715 |
+
if not edge_id:
|
| 716 |
+
return None
|
| 717 |
+
for e in self.state_data.network_edges:
|
| 718 |
+
if e.edge_id == edge_id:
|
| 719 |
+
return e
|
| 720 |
+
return None
|
| 721 |
+
|
| 722 |
+
def _inter_dc_edge_ids(self) -> List[str]:
|
| 723 |
+
return [e.edge_id for e in self.state_data.network_edges
|
| 724 |
+
if e.source.split("_")[0] != e.target.split("_")[0]]
|
| 725 |
+
|
| 726 |
+
# ββ TASK-SPECIFIC PROMPT RULES ββββββββββββββββββββββββββββββββββββββββββββ
|
| 727 |
+
|
| 728 |
+
def _task_specific_rules(self) -> str:
|
| 729 |
+
if self.current_task == "cascading_deadlock":
|
| 730 |
+
return (
|
| 731 |
+
"\n\nTASK-SPECIFIC CONTEXT (CASCADING DEADLOCK):\n"
|
| 732 |
+
"- This is a 2-phase incident: a network degradation AND a Redis cache time-bomb.\n"
|
| 733 |
+
"- The Redis cache usage is climbing every step. When it hits 100%, Auth goes OFFLINE.\n"
|
| 734 |
+
"- You MUST fix the network first, BUT if auth_status becomes 'offline', you must\n"
|
| 735 |
+
" IMMEDIATELY delegate to dataops to run clear_cache before doing anything else.\n"
|
| 736 |
+
"- The incident is only resolved when: network is fixed AND auth is online AND redis is cleared.\n"
|
| 737 |
+
"- After clearing the cache, resume fixing the network if not already done."
|
| 738 |
+
)
|
| 739 |
+
elif self.current_task == "regional_wipeout":
|
| 740 |
+
return (
|
| 741 |
+
"\n\nTASK-SPECIFIC CONTEXT (REGIONAL WIPEOUT - 3 DATA CENTERS):\n"
|
| 742 |
+
"- Topology: DC-Alpha(dc1), DC-Beta(dc2), DC-Gamma(dc3).\n"
|
| 743 |
+
"- dc1βdc2 primary link is SEVERED (partitioned).\n"
|
| 744 |
+
"- dc2 is sending a replication storm to dc3, saturating dc3 bandwidth at 100%.\n"
|
| 745 |
+
"- CYCLIC DEPENDENCY: You cannot route through dc3 (saturated) and you cannot\n"
|
| 746 |
+
" reach dc2 to stop the storm (link severed). Standard routing WILL FAIL.\n"
|
| 747 |
+
"- Valid inter-DC edges: dc1_router--dc3_router, dc2_router--dc3_router, dc1_router--dc2_router\n"
|
| 748 |
+
"- Special target_id: 'oob_tunnel' (creates a management link, but only works AFTER dc3 bandwidth is throttled below 100)\n"
|
| 749 |
+
"\n"
|
| 750 |
+
"EXACT STEP-BY-STEP SEQUENCE FOR NETOPS:\n"
|
| 751 |
+
" Step A: throttle_bandwidth on dc2_router--dc3_router with limit_pct=10 (MUST be 10, NOT 50 or 90!)\n"
|
| 752 |
+
" JSON: {\"action_type\": \"throttle_bandwidth\", \"target_id\": \"dc2_router--dc3_router\", \"parameters\": {\"limit_pct\": 10}}\n"
|
| 753 |
+
" Step B: update_route with target_id 'oob_tunnel' (only works after Step A)\n"
|
| 754 |
+
" JSON: {\"action_type\": \"update_route\", \"target_id\": \"oob_tunnel\"}\n"
|
| 755 |
+
" Step C: verify_routing\n"
|
| 756 |
+
" Step D: delegate back to orchestrator\n"
|
| 757 |
+
"\n"
|
| 758 |
+
"EXACT STEP-BY-STEP SEQUENCE FOR DATAOPS:\n"
|
| 759 |
+
" Step E: stop_replication (only works after OOB tunnel is active)\n"
|
| 760 |
+
" Step F: force_stepdown on dc2\n"
|
| 761 |
+
" Step G: reconcile_ledger\n"
|
| 762 |
+
" Step H: delegate back to orchestrator\n"
|
| 763 |
+
"\nCRITICAL INSTRUCTION FOR ALL AGENTS: You MUST strictly obey the DELEGATION CONTEXT. "
|
| 764 |
+
"Do exactly what the delegation context tells you to do, and nothing else. "
|
| 765 |
+
"Do not skip steps. Do not guess future steps."
|
| 766 |
+
)
|
| 767 |
+
return ""
|
| 768 |
+
|
| 769 |
+
# ββ LLM PROMPTS βββββββββββββββββββββββββββββββοΏ½οΏ½οΏ½βββββββββββββββββββββββββ
|
| 770 |
+
|
| 771 |
+
def get_llm_prompts(self):
|
| 772 |
+
"""Return (system_prompt, user_prompt) for the current actor."""
|
| 773 |
+
actor = self.state_data.current_actor
|
| 774 |
+
sys_prompt = SYSTEM_PROMPTS.get(actor, SYSTEM_PROMPTS["orchestrator"])
|
| 775 |
+
obs = self.state_data
|
| 776 |
+
|
| 777 |
+
ctx = f"\nDELEGATION CONTEXT: {obs.delegation_context}" if obs.delegation_context else ""
|
| 778 |
+
user_prompt = f"""You are the {actor.upper()} agent handling a datacenter incident.
|
| 779 |
+
{ctx}
|
| 780 |
+
|
| 781 |
+
CURRENT STATE:
|
| 782 |
+
{obs.model_dump_json(indent=2)}
|
| 783 |
+
|
| 784 |
+
RULES:
|
| 785 |
+
1. ORCHESTRATOR SEQUENCE OF REPAIRS:
|
| 786 |
+
- First, MUST run_diagnostic to identify the exact issue if it is unknown. (NEVER run this more than once. If it is already in recent_events, proceed to Step 2).
|
| 787 |
+
- Second, delegate to netops to fix the network partition and verify routing.
|
| 788 |
+
- Third, once the network is verified, delegate to dataops to halt the replication storm.
|
| 789 |
+
- Fourth, delegate to dataops to resolve the split-brain (force stepdown) and reconcile the ledger.
|
| 790 |
+
- EMERGENCY: If a critical system like Auth goes offline (auth_status=offline), you must IMMEDIATELY preempt current operations, delegate to dataops to clear_cache, and then resume normal operations.
|
| 791 |
+
2. Network MUST be fixed before database operations.
|
| 792 |
+
3. Kill replication storms before reconciling data.
|
| 793 |
+
4. NEVER reconcile_ledger unless routing is verified.
|
| 794 |
+
5. When your work is done, delegate back to orchestrator.
|
| 795 |
+
6. DO NOT output noop if there is an active incident. Always take action or delegate.
|
| 796 |
+
7. If a command fails with SSH Timeout or Node unresponsive, RETRY the same command.
|
| 797 |
+
{self._task_specific_rules()}
|
| 798 |
+
|
| 799 |
+
Respond with EXACTLY ONE JSON object. No other text.
|
| 800 |
+
Valid actions for {actor}: {sorted(ACTOR_ACTIONS[actor])}
|
| 801 |
+
|
| 802 |
+
EXAMPLES:
|
| 803 |
+
{{"action_type": "run_diagnostic"}}
|
| 804 |
+
{{"action_type": "clear_cache"}}
|
| 805 |
+
{{"action_type": "delegate", "target_agent": "netops", "instruction_payload": "Establish bypass routing"}}
|
| 806 |
+
{{"action_type": "delegate", "target_agent": "dataops", "instruction_payload": "Stop replication storm and reconcile ledger"}}
|
| 807 |
+
{{"action_type": "update_route", "target_id": "dc1_router--dc2_switch"}}
|
| 808 |
+
{{"action_type": "throttle_bandwidth", "target_id": "dc2_router--dc3_router", "parameters": {{"limit_pct": 10}}}}
|
| 809 |
+
{{"action_type": "update_route", "target_id": "oob_tunnel"}}
|
| 810 |
+
{{"action_type": "stop_replication"}}
|
| 811 |
+
{{"action_type": "force_stepdown", "target_id": "dc2"}}
|
| 812 |
+
"""
|
| 813 |
+
return sys_prompt, user_prompt
|
agents/split_brain/models.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
agents/split_brain/models.py
|
| 3 |
+
=============================
|
| 4 |
+
Pydantic data models for the Split-Brain Collapse environment.
|
| 5 |
+
Defines observation space, action space, and step result types.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from pydantic import BaseModel, Field
|
| 9 |
+
from typing import List, Optional, Literal, Dict, Any
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
# ββ NETWORK LAYER ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 13 |
+
|
| 14 |
+
class NetworkNode(BaseModel):
|
| 15 |
+
"""A single node in the datacenter network topology."""
|
| 16 |
+
node_id: str
|
| 17 |
+
datacenter: Literal["dc1", "dc2", "dc3"]
|
| 18 |
+
node_type: Literal["router", "switch", "server"]
|
| 19 |
+
status: Literal["online", "offline", "degraded", "partitioned"]
|
| 20 |
+
cpu_usage: float = Field(0.0, description="CPU usage percentage")
|
| 21 |
+
bandwidth_used: float = Field(0.0, description="Bandwidth used in Mbps")
|
| 22 |
+
bandwidth_capacity: float = Field(1000.0, description="Total bandwidth capacity in Mbps")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class NetworkEdge(BaseModel):
|
| 26 |
+
"""A link between two network nodes."""
|
| 27 |
+
edge_id: str
|
| 28 |
+
source: str
|
| 29 |
+
target: str
|
| 30 |
+
status: Literal["healthy", "severed", "congested", "bypass", "offline"]
|
| 31 |
+
latency_ms: float = Field(1.0, description="Link latency in milliseconds")
|
| 32 |
+
bandwidth_used: float = Field(0.0, description="Current bandwidth usage in Mbps")
|
| 33 |
+
bandwidth_capacity: float = Field(1000.0, description="Max bandwidth capacity in Mbps")
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
# ββ STORAGE LAYER ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 37 |
+
|
| 38 |
+
class HDFSCluster(BaseModel):
|
| 39 |
+
"""State of the HDFS distributed storage cluster."""
|
| 40 |
+
total_blocks: int = Field(1000, description="Total data blocks in HDFS")
|
| 41 |
+
io_bandwidth_used_pct: float = Field(0.0, description="I/O bandwidth usage 0-100%")
|
| 42 |
+
replication_storm_active: bool = Field(False, description="Whether a replication storm is in progress")
|
| 43 |
+
replication_factor: int = Field(3, description="Current block replication factor")
|
| 44 |
+
target_replication_factor: int = Field(3, description="Desired replication factor")
|
| 45 |
+
under_replicated_blocks: int = Field(0, description="Blocks below target replication")
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
# ββ DATABASE LAYER βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 49 |
+
|
| 50 |
+
class LedgerEntry(BaseModel):
|
| 51 |
+
"""A single row in the NewSQL ledger showing potential conflict."""
|
| 52 |
+
key: str
|
| 53 |
+
dc1_value: Optional[str] = None
|
| 54 |
+
dc2_value: Optional[str] = None
|
| 55 |
+
conflict: bool = False
|
| 56 |
+
timestamp_dc1: Optional[float] = None
|
| 57 |
+
timestamp_dc2: Optional[float] = None
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class DatabaseState(BaseModel):
|
| 61 |
+
"""State of the NewSQL distributed database."""
|
| 62 |
+
dc1_role: Literal["leader", "follower", "isolated"] = "leader"
|
| 63 |
+
dc2_role: Literal["leader", "follower", "isolated"] = "follower"
|
| 64 |
+
split_brain_active: bool = False
|
| 65 |
+
conflicting_entries: int = 0
|
| 66 |
+
total_entries: int = Field(500, description="Total ledger rows")
|
| 67 |
+
ledger_sample: List[LedgerEntry] = Field(default_factory=list,
|
| 68 |
+
description="Sample rows for agent inspection")
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
# ββ GLOBAL OBSERVATION βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 72 |
+
|
| 73 |
+
class SplitBrainObservation(BaseModel):
|
| 74 |
+
"""Full environment state delivered to the agent each step."""
|
| 75 |
+
global_health: float = Field(0.0, ge=0.0, le=1.0, description="Overall system health 0.0-1.0")
|
| 76 |
+
current_actor: Literal["orchestrator", "netops", "dataops"] = "orchestrator"
|
| 77 |
+
step: int = 0
|
| 78 |
+
max_steps: int = 35
|
| 79 |
+
|
| 80 |
+
# Network
|
| 81 |
+
network_status: Literal["partitioned", "degraded", "healthy"] = "partitioned"
|
| 82 |
+
dc1_dc2_connected: bool = False
|
| 83 |
+
routing_verified: bool = False
|
| 84 |
+
oob_tunnel_active: bool = False
|
| 85 |
+
network_nodes: List[NetworkNode] = Field(default_factory=list)
|
| 86 |
+
network_edges: List[NetworkEdge] = Field(default_factory=list)
|
| 87 |
+
|
| 88 |
+
# Storage
|
| 89 |
+
hdfs: HDFSCluster = Field(default_factory=HDFSCluster)
|
| 90 |
+
|
| 91 |
+
# Database
|
| 92 |
+
newsql: DatabaseState = Field(default_factory=DatabaseState)
|
| 93 |
+
|
| 94 |
+
# Context
|
| 95 |
+
recent_events: List[str] = Field(default_factory=list)
|
| 96 |
+
delegation_context: Optional[str] = Field(None, description="Instruction from the delegating agent")
|
| 97 |
+
|
| 98 |
+
# Task 4 Additions
|
| 99 |
+
auth_status: Literal["online", "offline"] = "online"
|
| 100 |
+
redis_cache_usage: float = Field(0.0, description="Redis cache memory usage percentage")
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
# ββ ACTION SPACE βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 104 |
+
|
| 105 |
+
class SplitBrainAction(BaseModel):
|
| 106 |
+
"""A single action taken by the current actor."""
|
| 107 |
+
action_type: Literal[
|
| 108 |
+
# Orchestrator
|
| 109 |
+
"delegate",
|
| 110 |
+
"assess_situation",
|
| 111 |
+
# NetOps
|
| 112 |
+
"update_route",
|
| 113 |
+
"verify_routing",
|
| 114 |
+
"throttle_bandwidth",
|
| 115 |
+
# DataOps
|
| 116 |
+
"tune_hdfs",
|
| 117 |
+
"stop_replication",
|
| 118 |
+
"force_stepdown",
|
| 119 |
+
"reconcile_ledger",
|
| 120 |
+
"clear_cache",
|
| 121 |
+
# Universal
|
| 122 |
+
"noop",
|
| 123 |
+
"run_diagnostic",
|
| 124 |
+
]
|
| 125 |
+
target_id: Optional[str] = Field(None, description="Node ID, edge ID, or DC identifier to target")
|
| 126 |
+
parameters: Optional[Dict[str, Any]] = Field(None, description="Action-specific parameters")
|
| 127 |
+
# Delegate-specific fields
|
| 128 |
+
target_agent: Optional[Literal["orchestrator", "netops", "dataops"]] = Field(
|
| 129 |
+
None, description="Agent to delegate to (only for delegate action)")
|
| 130 |
+
instruction_payload: Optional[str] = Field(
|
| 131 |
+
None, description="Instructions for the target agent (only for delegate action)")
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
# ββ STEP RESULT ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 135 |
+
|
| 136 |
+
class SplitBrainStepResult(BaseModel):
|
| 137 |
+
"""Result of a single environment step β OpenEnv compliant."""
|
| 138 |
+
observation: SplitBrainObservation
|
| 139 |
+
reward: float = Field(0.0, description="Step reward signal")
|
| 140 |
+
done: bool = Field(False, description="Whether the episode has ended")
|
| 141 |
+
info: Dict[str, Any] = Field(default_factory=dict, description="Extra metadata including message")
|
app.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
app.py β OpenEnv Split-Brain Collapse Environment
|
| 3 |
+
==================================================
|
| 4 |
+
Serves:
|
| 5 |
+
β’ FastAPI HTTP API β /reset, /step, /state, /health, /tasks
|
| 6 |
+
β’ Agent endpoints β /agents, /agent/step
|
| 7 |
+
β’ Static HTML UI β GET / β static/index.html
|
| 8 |
+
All on port 7860 for Hugging Face Spaces.
|
| 9 |
+
|
| 10 |
+
ARCHITECTURE:
|
| 11 |
+
agents/__init__.py β AGENT_REGISTRY (central config)
|
| 12 |
+
agents/<name>/ β each agent's env_class + task metadata
|
| 13 |
+
Add a new agent = new folder + one import + one dict entry
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
import os
|
| 17 |
+
from dotenv import load_dotenv
|
| 18 |
+
from typing import Optional
|
| 19 |
+
|
| 20 |
+
from fastapi import FastAPI, HTTPException, Request
|
| 21 |
+
from fastapi.responses import JSONResponse
|
| 22 |
+
from fastapi.staticfiles import StaticFiles
|
| 23 |
+
from pydantic import BaseModel, Field
|
| 24 |
+
import uvicorn
|
| 25 |
+
from openai import OpenAI
|
| 26 |
+
|
| 27 |
+
from agents import AGENT_REGISTRY
|
| 28 |
+
from agents.split_brain.models import SplitBrainAction
|
| 29 |
+
|
| 30 |
+
load_dotenv()
|
| 31 |
+
|
| 32 |
+
# ββ LLM Config ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 33 |
+
API_BASE_URL = os.getenv("API_BASE_URL", "https://router.huggingface.co/v1")
|
| 34 |
+
MODEL_NAME = os.getenv("MODEL_NAME", "openai/gpt-oss-120b")
|
| 35 |
+
SPLIT_BRAIN_MODEL = os.getenv("SPLIT_BRAIN_MODEL", "") # GRPO fine-tuned LoRA model
|
| 36 |
+
API_KEY = os.getenv("HF_TOKEN", "").strip().strip('"').strip("'")
|
| 37 |
+
|
| 38 |
+
if API_KEY:
|
| 39 |
+
llm_client = OpenAI(
|
| 40 |
+
base_url=API_BASE_URL,
|
| 41 |
+
api_key=API_KEY,
|
| 42 |
+
timeout=300.0,
|
| 43 |
+
)
|
| 44 |
+
print(f"[INFO] LLM ready. Token: {API_KEY[:6]}... Model: {MODEL_NAME}")
|
| 45 |
+
else:
|
| 46 |
+
llm_client = None
|
| 47 |
+
print("[WARN] HF_TOKEN not set β LLM calls will fail.")
|
| 48 |
+
|
| 49 |
+
# ββ Active Environment State βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 50 |
+
active_agent_id = None
|
| 51 |
+
active_env = None
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def get_or_create_env(agent_id: str):
|
| 55 |
+
"""Lazily create/switch the active environment when agent changes."""
|
| 56 |
+
global active_agent_id, active_env
|
| 57 |
+
agent = AGENT_REGISTRY.get(agent_id)
|
| 58 |
+
if not agent:
|
| 59 |
+
raise HTTPException(
|
| 60 |
+
status_code=400,
|
| 61 |
+
detail=f"Unknown agent '{agent_id}'. Available: {list(AGENT_REGISTRY.keys())}",
|
| 62 |
+
)
|
| 63 |
+
if active_agent_id != agent_id:
|
| 64 |
+
active_agent_id = agent_id
|
| 65 |
+
active_env = agent["env_class"]()
|
| 66 |
+
return active_env
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
# ββ FastAPI App βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 70 |
+
fastapi_app = FastAPI(
|
| 71 |
+
title="OpenEnv: Split-Brain Collapse",
|
| 72 |
+
description=(
|
| 73 |
+
"An OpenEnv-compliant multi-agent RL environment simulating a "
|
| 74 |
+
"three-datacenter split-brain network partition crisis. "
|
| 75 |
+
"AI agents acting as orchestrator, netops, and dba must collaboratively "
|
| 76 |
+
"resolve network partitions, replication storms, and cascading deadlocks."
|
| 77 |
+
),
|
| 78 |
+
version="1.0.0",
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 83 |
+
# CORE RL API (openenv validate + external agents)
|
| 84 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 85 |
+
|
| 86 |
+
@fastapi_app.get("/health")
|
| 87 |
+
def health():
|
| 88 |
+
"""Health check β returns 200 OK."""
|
| 89 |
+
return {
|
| 90 |
+
"status": "ok",
|
| 91 |
+
"env": "split-brain",
|
| 92 |
+
"version": "1.0.0",
|
| 93 |
+
"llm_configured": bool(API_KEY),
|
| 94 |
+
"model": MODEL_NAME,
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
@fastapi_app.post("/reset")
|
| 99 |
+
async def reset(request: Request):
|
| 100 |
+
"""
|
| 101 |
+
Reset the environment for a given task.
|
| 102 |
+
Body: {"task": "partition_basic", "agent_id": "split_brain"}
|
| 103 |
+
"""
|
| 104 |
+
try:
|
| 105 |
+
body = await request.json()
|
| 106 |
+
except Exception:
|
| 107 |
+
body = {}
|
| 108 |
+
|
| 109 |
+
agent_id = body.get("agent_id", "split_brain")
|
| 110 |
+
task = body.get("task", "partition_basic")
|
| 111 |
+
env = get_or_create_env(agent_id)
|
| 112 |
+
agent = AGENT_REGISTRY[agent_id]
|
| 113 |
+
valid = [t["id"] for t in agent["tasks"]]
|
| 114 |
+
if task not in valid:
|
| 115 |
+
raise HTTPException(
|
| 116 |
+
status_code=400,
|
| 117 |
+
detail=f"Unknown task '{task}'. Valid: {valid}",
|
| 118 |
+
)
|
| 119 |
+
obs = env.reset(task=task)
|
| 120 |
+
return JSONResponse(content=obs.model_dump())
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
@fastapi_app.post("/step")
|
| 124 |
+
async def step(request: Request):
|
| 125 |
+
"""Execute one raw action in the environment."""
|
| 126 |
+
if active_env is None:
|
| 127 |
+
raise HTTPException(status_code=400, detail="No environment loaded. Call /reset first.")
|
| 128 |
+
body = await request.json()
|
| 129 |
+
action = SplitBrainAction(**body)
|
| 130 |
+
result = active_env.step(action)
|
| 131 |
+
return JSONResponse(content=result.model_dump())
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
@fastapi_app.get("/state")
|
| 135 |
+
def state():
|
| 136 |
+
"""Return the current observation without advancing the episode."""
|
| 137 |
+
if active_env is None:
|
| 138 |
+
raise HTTPException(status_code=400, detail="No environment loaded. Call /reset first.")
|
| 139 |
+
return JSONResponse(content=active_env.state().model_dump())
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
@fastapi_app.get("/tasks")
|
| 143 |
+
def list_tasks(agent_id: str = "split_brain"):
|
| 144 |
+
"""List all available tasks with difficulty metadata for a specific agent."""
|
| 145 |
+
if agent_id not in AGENT_REGISTRY:
|
| 146 |
+
raise HTTPException(status_code=404, detail="Agent not found.")
|
| 147 |
+
return {"tasks": AGENT_REGISTRY[agent_id]["tasks"]}
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 151 |
+
# AGENT REGISTRY API (frontend discovers agents + tasks)
|
| 152 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 153 |
+
|
| 154 |
+
@fastapi_app.get("/agents")
|
| 155 |
+
def list_agents():
|
| 156 |
+
"""
|
| 157 |
+
Return all registered agents with their tasks.
|
| 158 |
+
The frontend sidebar auto-populates from this endpoint.
|
| 159 |
+
Add new agents to agents/__init__.py β no HTML changes needed.
|
| 160 |
+
"""
|
| 161 |
+
return [
|
| 162 |
+
{
|
| 163 |
+
"id": agent["id"],
|
| 164 |
+
"name": agent["name"],
|
| 165 |
+
"icon": agent["icon"],
|
| 166 |
+
"description": agent["description"],
|
| 167 |
+
"tasks": agent["tasks"],
|
| 168 |
+
}
|
| 169 |
+
for agent in AGENT_REGISTRY.values()
|
| 170 |
+
]
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 174 |
+
# LLM AGENT STEP (used by the HTML UI's βΆ Agent Step button)
|
| 175 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 176 |
+
|
| 177 |
+
class AgentStepRequest(BaseModel):
|
| 178 |
+
agent_id: str = Field("split_brain", description="Which agent (environment) to step")
|
| 179 |
+
task: Optional[str] = Field(None, description="Task hint (set via /reset)")
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
@fastapi_app.post("/agent/step")
|
| 183 |
+
def agent_step(body: AgentStepRequest):
|
| 184 |
+
"""
|
| 185 |
+
Run ONE LLM-powered step on the active Split-Brain environment.
|
| 186 |
+
The environment provides its own multi-agent prompts via get_llm_prompts().
|
| 187 |
+
"""
|
| 188 |
+
if not llm_client:
|
| 189 |
+
raise HTTPException(status_code=503, detail="HF_TOKEN not configured.")
|
| 190 |
+
|
| 191 |
+
env = get_or_create_env(body.agent_id)
|
| 192 |
+
obs = env.state()
|
| 193 |
+
|
| 194 |
+
# Check episode completion
|
| 195 |
+
health = getattr(obs, "global_health", None) or getattr(obs, "health_score", 0)
|
| 196 |
+
if health >= 1.0 or env.step_count >= env.max_steps:
|
| 197 |
+
raise HTTPException(status_code=400, detail="Episode complete. Call /reset to start a new one.")
|
| 198 |
+
|
| 199 |
+
# The split_brain env always exposes get_llm_prompts() for multi-agent routing
|
| 200 |
+
system_prompt, user_prompt = env.get_llm_prompts()
|
| 201 |
+
|
| 202 |
+
# Auto-select model: prefer fine-tuned LoRA model for cascading_deadlock
|
| 203 |
+
active_model = MODEL_NAME
|
| 204 |
+
if SPLIT_BRAIN_MODEL and getattr(env, "current_task", "") == "cascading_deadlock":
|
| 205 |
+
active_model = SPLIT_BRAIN_MODEL
|
| 206 |
+
print(f"[INFO] Using fine-tuned model: {active_model}")
|
| 207 |
+
|
| 208 |
+
try:
|
| 209 |
+
completion = llm_client.chat.completions.create(
|
| 210 |
+
model=active_model,
|
| 211 |
+
messages=[
|
| 212 |
+
{"role": "system", "content": system_prompt},
|
| 213 |
+
{"role": "user", "content": user_prompt},
|
| 214 |
+
],
|
| 215 |
+
temperature=0.1,
|
| 216 |
+
max_tokens=400,
|
| 217 |
+
)
|
| 218 |
+
raw_text = completion.choices[0].message.content or ""
|
| 219 |
+
print(f"[DEBUG LLM ({active_model})] {raw_text}")
|
| 220 |
+
except Exception as e:
|
| 221 |
+
raise HTTPException(status_code=502, detail=f"LLM call failed: {str(e)}")
|
| 222 |
+
|
| 223 |
+
action = env._parse_action(raw_text)
|
| 224 |
+
result = env.step(action)
|
| 225 |
+
new_obs = result.observation
|
| 226 |
+
msg = result.info.get("message", "")
|
| 227 |
+
|
| 228 |
+
return {
|
| 229 |
+
"step": env.step_count,
|
| 230 |
+
"agent_id": body.agent_id,
|
| 231 |
+
"action": action.model_dump(),
|
| 232 |
+
"reward": result.reward,
|
| 233 |
+
"done": result.done,
|
| 234 |
+
"message": msg,
|
| 235 |
+
"observation": new_obs.model_dump(),
|
| 236 |
+
"current_actor": result.info.get("current_actor", "orchestrator"),
|
| 237 |
+
"delegation_log": result.info.get("delegation_log", []),
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββοΏ½οΏ½οΏ½ββββββββββββββββββββ
|
| 242 |
+
# STATIC FILES β serves static/index.html at /
|
| 243 |
+
# Mount AFTER all API routes so API routes take priority.
|
| 244 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 245 |
+
fastapi_app.mount("/", StaticFiles(directory="static", html=True), name="static")
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
# ββ Launch ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 249 |
+
if __name__ == "__main__":
|
| 250 |
+
uvicorn.run(fastapi_app, host="0.0.0.0", port=7860, log_level="info")
|
inference.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
inference.py β OpenEnv: Split-Brain Collapse
|
| 3 |
+
=============================================
|
| 4 |
+
Mandatory STDOUT format (parsed by the automated validator):
|
| 5 |
+
|
| 6 |
+
[START] task=<task_name> env=<benchmark> model=<model_name>
|
| 7 |
+
[STEP] step=<n> action=<action_str> reward=<0.00> done=<true|false> error=<msg|null>
|
| 8 |
+
[END] success=<true|false> steps=<n> score=<score> rewards=<r1,r2,...,rn>
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import os
|
| 12 |
+
from typing import List, Optional
|
| 13 |
+
from openai import OpenAI
|
| 14 |
+
from dotenv import load_dotenv
|
| 15 |
+
|
| 16 |
+
from agents.split_brain.environment import SplitBrainEnv
|
| 17 |
+
|
| 18 |
+
load_dotenv()
|
| 19 |
+
|
| 20 |
+
# ββ 1. Load Required Environment Variables ββββββββββββββββββββββββββββββββββ
|
| 21 |
+
API_BASE_URL = os.getenv("API_BASE_URL", "https://router.huggingface.co/v1")
|
| 22 |
+
API_KEY = os.getenv("HF_TOKEN") or os.getenv("API_KEY", "")
|
| 23 |
+
MODEL_NAME = os.getenv("MODEL_NAME", "deepseek-ai/DeepSeek-R1-Distill-Llama-70B")
|
| 24 |
+
|
| 25 |
+
BENCHMARK = "split-brain"
|
| 26 |
+
SUCCESS_SCORE_THRESHOLD = 0.5
|
| 27 |
+
|
| 28 |
+
if not API_KEY:
|
| 29 |
+
raise EnvironmentError("CRITICAL: HF_TOKEN or API_KEY environment variable is required.")
|
| 30 |
+
|
| 31 |
+
client = OpenAI(base_url=API_BASE_URL, api_key=API_KEY)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def log_start(task: str, env: str, model: str) -> None:
|
| 35 |
+
print(f"[START] task={task} env={env} model={model}", flush=True)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def log_step(step: int, action: str, reward: float, done: bool, error: Optional[str]) -> None:
|
| 39 |
+
error_val = error if error else "null"
|
| 40 |
+
done_val = str(done).lower()
|
| 41 |
+
print(f"[STEP] step={step} action={action} reward={reward:.2f} done={done_val} error={error_val}", flush=True)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def log_end(success: bool, steps: int, score: float, rewards: List[float]) -> None:
|
| 45 |
+
rewards_str = ",".join(f"{r:.2f}" for r in rewards)
|
| 46 |
+
print(f"[END] success={str(success).lower()} steps={steps} score={score:.2f} rewards={rewards_str}", flush=True)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def run_task(env: SplitBrainEnv, task_id: str) -> float:
|
| 50 |
+
log_start(task=task_id, env=BENCHMARK, model=MODEL_NAME)
|
| 51 |
+
|
| 52 |
+
rewards: List[float] = []
|
| 53 |
+
steps_taken = 0
|
| 54 |
+
score = 0.01 # Safe default strictly > 0
|
| 55 |
+
success = False
|
| 56 |
+
|
| 57 |
+
max_steps = env.max_steps if hasattr(env, "max_steps") else 50
|
| 58 |
+
|
| 59 |
+
try:
|
| 60 |
+
obs = env.reset(task=task_id)
|
| 61 |
+
|
| 62 |
+
for step in range(1, max_steps + 1):
|
| 63 |
+
# The split_brain env provides context-aware multi-agent prompts
|
| 64 |
+
system_prompt, user_prompt = env.get_llm_prompts()
|
| 65 |
+
|
| 66 |
+
last_error = None
|
| 67 |
+
try:
|
| 68 |
+
completion = client.chat.completions.create(
|
| 69 |
+
model=MODEL_NAME,
|
| 70 |
+
messages=[
|
| 71 |
+
{"role": "system", "content": system_prompt},
|
| 72 |
+
{"role": "user", "content": user_prompt},
|
| 73 |
+
],
|
| 74 |
+
temperature=0.1,
|
| 75 |
+
max_tokens=400,
|
| 76 |
+
)
|
| 77 |
+
response_text = completion.choices[0].message.content or ""
|
| 78 |
+
except Exception as e:
|
| 79 |
+
last_error = str(e)
|
| 80 |
+
log_step(step=step, action="noop", reward=0.0, done=True, error=last_error)
|
| 81 |
+
rewards.append(0.0)
|
| 82 |
+
steps_taken = step
|
| 83 |
+
break
|
| 84 |
+
|
| 85 |
+
action = env._parse_action(response_text)
|
| 86 |
+
action_str = f"{action.action_type}"
|
| 87 |
+
if getattr(action, "target_id", None):
|
| 88 |
+
action_str += f"({action.target_id})"
|
| 89 |
+
elif getattr(action, "target_agent", None):
|
| 90 |
+
action_str += f"β{action.target_agent}"
|
| 91 |
+
|
| 92 |
+
result = env.step(action)
|
| 93 |
+
obs = result.observation
|
| 94 |
+
reward = result.reward
|
| 95 |
+
done = result.done
|
| 96 |
+
|
| 97 |
+
rewards.append(reward)
|
| 98 |
+
steps_taken = step
|
| 99 |
+
|
| 100 |
+
log_step(step=step, action=action_str, reward=reward, done=done, error=None)
|
| 101 |
+
|
| 102 |
+
if done:
|
| 103 |
+
break
|
| 104 |
+
|
| 105 |
+
# Clamp score strictly between (0, 1)
|
| 106 |
+
final_health = getattr(obs, "global_health", getattr(obs, "health_score", 0.0))
|
| 107 |
+
success = final_health >= 1.0
|
| 108 |
+
raw_score = 1.0 if success else max(0.0, final_health)
|
| 109 |
+
score = max(0.01, min(0.99, raw_score))
|
| 110 |
+
|
| 111 |
+
except Exception:
|
| 112 |
+
score = 0.01
|
| 113 |
+
success = False
|
| 114 |
+
|
| 115 |
+
finally:
|
| 116 |
+
log_end(success=success, steps=steps_taken, score=score, rewards=rewards)
|
| 117 |
+
|
| 118 |
+
return score
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def main():
|
| 122 |
+
env = SplitBrainEnv()
|
| 123 |
+
tasks = [
|
| 124 |
+
"partition_basic",
|
| 125 |
+
"replication_storm",
|
| 126 |
+
"split_brain",
|
| 127 |
+
"cascading_deadlock",
|
| 128 |
+
"regional_wipeout",
|
| 129 |
+
]
|
| 130 |
+
for task_id in tasks:
|
| 131 |
+
env.reset(task=task_id) # re-use same env instance
|
| 132 |
+
run_task(env, task_id)
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
if __name__ == "__main__":
|
| 136 |
+
main()
|
inference_lora.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
inference_lora.py β Local LoRA Inference for Split-Brain Environment
|
| 3 |
+
=====================================================================
|
| 4 |
+
Loads the GRPO-trained LoRA adapter (openenv-split-brain-lora/) on top of
|
| 5 |
+
Llama-3.2-3B-Instruct and runs it against the Split-Brain environment.
|
| 6 |
+
|
| 7 |
+
Usage (requires GPU with >=6GB VRAM, or run on Colab):
|
| 8 |
+
pip install unsloth peft transformers torch
|
| 9 |
+
python inference_lora.py
|
| 10 |
+
|
| 11 |
+
This script demonstrates the improvement of the fine-tuned model over
|
| 12 |
+
the base model on the Split-Brain cascading_deadlock task (Task 4).
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import os
|
| 16 |
+
import json
|
| 17 |
+
import re
|
| 18 |
+
import torch
|
| 19 |
+
from typing import List, Optional
|
| 20 |
+
|
| 21 |
+
from agents.split_brain.environment import SplitBrainEnv
|
| 22 |
+
from agents.split_brain.models import SplitBrainAction
|
| 23 |
+
|
| 24 |
+
# ββ 1. Load Model + LoRA Adapter ββββββββββββββββββββββββββββββββββββββββββββ
|
| 25 |
+
|
| 26 |
+
LORA_PATH = os.path.join(os.path.dirname(__file__), "openenv-split-brain-lora")
|
| 27 |
+
BASE_MODEL = "unsloth/Llama-3.2-3B-Instruct-bnb-4bit"
|
| 28 |
+
MAX_STEPS = 15
|
| 29 |
+
|
| 30 |
+
print(f"[INFO] Loading base model: {BASE_MODEL}")
|
| 31 |
+
print(f"[INFO] Applying LoRA adapter from: {LORA_PATH}")
|
| 32 |
+
|
| 33 |
+
try:
|
| 34 |
+
from unsloth import FastLanguageModel
|
| 35 |
+
|
| 36 |
+
model, tokenizer = FastLanguageModel.from_pretrained(
|
| 37 |
+
model_name=BASE_MODEL,
|
| 38 |
+
max_seq_length=1024,
|
| 39 |
+
load_in_4bit=True,
|
| 40 |
+
fast_inference=False,
|
| 41 |
+
)
|
| 42 |
+
# Load the trained LoRA weights on top
|
| 43 |
+
model.load_adapter(LORA_PATH, adapter_name="split_brain_lora")
|
| 44 |
+
FastLanguageModel.for_inference(model)
|
| 45 |
+
print("[INFO] LoRA adapter loaded successfully via Unsloth.")
|
| 46 |
+
USE_UNSLOTH = True
|
| 47 |
+
|
| 48 |
+
except ImportError:
|
| 49 |
+
# Fallback: use raw transformers + PEFT (no Unsloth needed)
|
| 50 |
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
| 51 |
+
from peft import PeftModel
|
| 52 |
+
|
| 53 |
+
print("[INFO] Unsloth not available, falling back to transformers + PEFT...")
|
| 54 |
+
tokenizer = AutoTokenizer.from_pretrained(BASE_MODEL)
|
| 55 |
+
base_model = AutoModelForCausalLM.from_pretrained(
|
| 56 |
+
BASE_MODEL,
|
| 57 |
+
torch_dtype=torch.float16,
|
| 58 |
+
device_map="auto",
|
| 59 |
+
)
|
| 60 |
+
model = PeftModel.from_pretrained(base_model, LORA_PATH)
|
| 61 |
+
model.eval()
|
| 62 |
+
print("[INFO] LoRA adapter loaded successfully via PEFT.")
|
| 63 |
+
USE_UNSLOTH = False
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
# ββ 2. Local Generation Function ββββββββββββββββββββββββββββββββββββββββββββ
|
| 67 |
+
|
| 68 |
+
def generate_action(system_prompt: str, user_prompt: str) -> str:
|
| 69 |
+
"""Generate a single action using the local LoRA-tuned model."""
|
| 70 |
+
messages = [
|
| 71 |
+
{"role": "system", "content": system_prompt},
|
| 72 |
+
{"role": "user", "content": user_prompt},
|
| 73 |
+
]
|
| 74 |
+
|
| 75 |
+
input_text = tokenizer.apply_chat_template(
|
| 76 |
+
messages, tokenize=False, add_generation_prompt=True
|
| 77 |
+
)
|
| 78 |
+
inputs = tokenizer(input_text, return_tensors="pt").to(model.device)
|
| 79 |
+
|
| 80 |
+
with torch.no_grad():
|
| 81 |
+
outputs = model.generate(
|
| 82 |
+
**inputs,
|
| 83 |
+
max_new_tokens=256,
|
| 84 |
+
temperature=0.1,
|
| 85 |
+
do_sample=True,
|
| 86 |
+
top_p=0.9,
|
| 87 |
+
pad_token_id=tokenizer.eos_token_id,
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
# Decode only the generated tokens (skip the prompt)
|
| 91 |
+
generated = outputs[0][inputs["input_ids"].shape[1]:]
|
| 92 |
+
return tokenizer.decode(generated, skip_special_tokens=True)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
# ββ 3. Parse LLM Output into Action βββββββββββββββββββββββββββββββββββββββββ
|
| 96 |
+
|
| 97 |
+
def parse_action(text: str) -> SplitBrainAction:
|
| 98 |
+
"""Extract a JSON action from the model's output text."""
|
| 99 |
+
# Strip thinking blocks
|
| 100 |
+
text = re.sub(r'<think>.*?</think>', '', text, flags=re.DOTALL).strip()
|
| 101 |
+
text = text.replace("```json", "").replace("```", "").strip()
|
| 102 |
+
|
| 103 |
+
match = re.search(r'\{.*\}', text, re.DOTALL)
|
| 104 |
+
if match:
|
| 105 |
+
try:
|
| 106 |
+
data = json.loads(match.group(0))
|
| 107 |
+
if "action_type" in data:
|
| 108 |
+
return SplitBrainAction(**data)
|
| 109 |
+
except Exception:
|
| 110 |
+
pass
|
| 111 |
+
|
| 112 |
+
# Fallback: noop
|
| 113 |
+
return SplitBrainAction(action_type="noop")
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
# ββ 4. Run the Split-Brain Episode ββββββββββββββββββββββββββββββββββββββββββ
|
| 117 |
+
|
| 118 |
+
def run_episode(task_id: str = "cascading_deadlock") -> dict:
|
| 119 |
+
"""Run a full episode on the Split-Brain environment using the LoRA model."""
|
| 120 |
+
env = SplitBrainEnv()
|
| 121 |
+
obs = env.reset(task=task_id)
|
| 122 |
+
|
| 123 |
+
rewards: List[float] = []
|
| 124 |
+
actions_taken: List[str] = []
|
| 125 |
+
total_reward = 0.0
|
| 126 |
+
|
| 127 |
+
print(f"\n{'='*70}")
|
| 128 |
+
print(f" SPLIT-BRAIN LORA INFERENCE β Task: {task_id}")
|
| 129 |
+
print(f" Model: {BASE_MODEL} + LoRA ({LORA_PATH})")
|
| 130 |
+
print(f"{'='*70}\n")
|
| 131 |
+
|
| 132 |
+
for step in range(1, MAX_STEPS + 1):
|
| 133 |
+
# Get prompts from the environment (multi-agent aware)
|
| 134 |
+
system_prompt, user_prompt = env.get_llm_prompts()
|
| 135 |
+
actor = env.state_data.current_actor
|
| 136 |
+
|
| 137 |
+
# Generate action with the LoRA model
|
| 138 |
+
raw_text = generate_action(system_prompt, user_prompt)
|
| 139 |
+
action = parse_action(raw_text)
|
| 140 |
+
|
| 141 |
+
# Step the environment
|
| 142 |
+
result = env.step(action)
|
| 143 |
+
reward = result.reward
|
| 144 |
+
done = result.done
|
| 145 |
+
msg = result.info.get("message", "")
|
| 146 |
+
|
| 147 |
+
rewards.append(reward)
|
| 148 |
+
total_reward += reward
|
| 149 |
+
actions_taken.append(action.action_type)
|
| 150 |
+
|
| 151 |
+
print(f"Step {step:2d} [{actor}] {action.action_type}"
|
| 152 |
+
f"{(' β ' + action.target_id) if action.target_id else ''}")
|
| 153 |
+
print(f" reward={reward:+.3f} | {msg}")
|
| 154 |
+
|
| 155 |
+
if done:
|
| 156 |
+
print(f"\n{'β'*70}")
|
| 157 |
+
print(f" β
EPISODE COMPLETE at step {step}")
|
| 158 |
+
break
|
| 159 |
+
else:
|
| 160 |
+
print(f"\n{'β'*70}")
|
| 161 |
+
print(f" β± MAX STEPS REACHED ({MAX_STEPS})")
|
| 162 |
+
|
| 163 |
+
# Summary
|
| 164 |
+
final_health = env.state_data.global_health
|
| 165 |
+
success = final_health >= 1.0
|
| 166 |
+
|
| 167 |
+
print(f" Final Health: {final_health:.2f}")
|
| 168 |
+
print(f" Total Reward: {total_reward:.3f}")
|
| 169 |
+
print(f" Success: {'YES β
' if success else 'NO β'}")
|
| 170 |
+
print(f" Actions: {' β '.join(actions_taken)}")
|
| 171 |
+
print(f"{'='*70}\n")
|
| 172 |
+
|
| 173 |
+
# Detect if the model got stuck in a loop
|
| 174 |
+
diagnostic_count = actions_taken.count("run_diagnostic")
|
| 175 |
+
if diagnostic_count > 2:
|
| 176 |
+
print(f" β οΈ WARNING: Model ran diagnostic {diagnostic_count} times (loop detected)")
|
| 177 |
+
elif diagnostic_count <= 1:
|
| 178 |
+
print(f" β
IMPROVEMENT: Model avoided the diagnostic loop!")
|
| 179 |
+
|
| 180 |
+
return {
|
| 181 |
+
"task": task_id,
|
| 182 |
+
"success": success,
|
| 183 |
+
"steps": len(rewards),
|
| 184 |
+
"total_reward": total_reward,
|
| 185 |
+
"final_health": final_health,
|
| 186 |
+
"actions": actions_taken,
|
| 187 |
+
"diagnostic_loops": diagnostic_count,
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
# ββ 5. Main βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 192 |
+
|
| 193 |
+
if __name__ == "__main__":
|
| 194 |
+
# Run the task that was previously failing with the base 8B model
|
| 195 |
+
result = run_episode("cascading_deadlock")
|
| 196 |
+
|
| 197 |
+
print("\n" + "="*70)
|
| 198 |
+
print(" COMPARISON SUMMARY")
|
| 199 |
+
print("="*70)
|
| 200 |
+
print(f" Before (base Llama 3.1 8B, no LoRA):")
|
| 201 |
+
print(f" β Stuck in infinite run_diagnostic loop (10+ repeats)")
|
| 202 |
+
print(f" β Never executed update_route, verify_routing, etc.")
|
| 203 |
+
print(f" β Episode timed out with minimal reward")
|
| 204 |
+
print(f"")
|
| 205 |
+
print(f" After (Llama 3.2 3B + GRPO LoRA):")
|
| 206 |
+
print(f" β Diagnostic loops: {result['diagnostic_loops']}")
|
| 207 |
+
print(f" β Actions taken: {' β '.join(result['actions'])}")
|
| 208 |
+
print(f" β Final health: {result['final_health']:.2f}")
|
| 209 |
+
print(f" β Success: {'YES β
' if result['success'] else 'NO β'}")
|
| 210 |
+
print("="*70)
|
openenv.yaml
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
# OpenEnv Specification β Split-Brain Collapse Environment
|
| 3 |
+
# Validate with: openenv validate
|
| 4 |
+
|
| 5 |
+
name: split-brain-env
|
| 6 |
+
version: "1.0.0"
|
| 7 |
+
description: >
|
| 8 |
+
Three-datacenter split-brain network partition crisis environment.
|
| 9 |
+
A multi-agent AI team acting as orchestrator, netops, and dba must
|
| 10 |
+
collaboratively diagnose and resolve network partitions, replication
|
| 11 |
+
storms, cascading deadlocks, and regional wipeouts across DC1, DC2,
|
| 12 |
+
and DC3 before the cluster enters an unrecoverable split-brain state.
|
| 13 |
+
|
| 14 |
+
author: "Team SoonValley"
|
| 15 |
+
license: "MIT"
|
| 16 |
+
|
| 17 |
+
tags:
|
| 18 |
+
- openenv
|
| 19 |
+
- infrastructure
|
| 20 |
+
- devops
|
| 21 |
+
- sre
|
| 22 |
+
- distributed-systems
|
| 23 |
+
- split-brain
|
| 24 |
+
- multi-agent
|
| 25 |
+
- network-partition
|
| 26 |
+
- real-world
|
| 27 |
+
|
| 28 |
+
# ββ Observation Space ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 29 |
+
observation_space:
|
| 30 |
+
type: object
|
| 31 |
+
description: "Full three-datacenter state snapshot delivered to the agent each step"
|
| 32 |
+
properties:
|
| 33 |
+
global_health:
|
| 34 |
+
type: number
|
| 35 |
+
minimum: 0.0
|
| 36 |
+
maximum: 1.0
|
| 37 |
+
description: "Continuous cluster stability metric across all three DCs"
|
| 38 |
+
current_actor:
|
| 39 |
+
type: string
|
| 40 |
+
enum: [orchestrator, netops, dba]
|
| 41 |
+
description: "Which sub-agent is active this step"
|
| 42 |
+
dc1_dc2_connected:
|
| 43 |
+
type: boolean
|
| 44 |
+
description: "Whether DC1 and DC2 network link is restored"
|
| 45 |
+
dc2_dc3_connected:
|
| 46 |
+
type: boolean
|
| 47 |
+
description: "Whether DC2 and DC3 network link is restored"
|
| 48 |
+
network_status:
|
| 49 |
+
type: string
|
| 50 |
+
enum: [partitioned, degraded, healthy]
|
| 51 |
+
replication_lag_ms:
|
| 52 |
+
type: number
|
| 53 |
+
description: "Database replication lag in milliseconds"
|
| 54 |
+
primary_db:
|
| 55 |
+
type: string
|
| 56 |
+
description: "Which DC currently holds the authoritative primary DB"
|
| 57 |
+
datacenters:
|
| 58 |
+
type: array
|
| 59 |
+
description: "List of 3 DatacenterStatus objects (dc1, dc2, dc3)"
|
| 60 |
+
items:
|
| 61 |
+
type: object
|
| 62 |
+
properties:
|
| 63 |
+
dc_id: { type: string }
|
| 64 |
+
status: { type: string, enum: [healthy, degraded, isolated, offline] }
|
| 65 |
+
node_count: { type: integer }
|
| 66 |
+
load_pct: { type: number }
|
| 67 |
+
recent_alerts:
|
| 68 |
+
type: array
|
| 69 |
+
items: { type: string }
|
| 70 |
+
description: "System log messages and critical alerts"
|
| 71 |
+
|
| 72 |
+
# ββ Action Space βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 73 |
+
action_space:
|
| 74 |
+
type: object
|
| 75 |
+
description: "A single command issued by the active sub-agent"
|
| 76 |
+
properties:
|
| 77 |
+
action_type:
|
| 78 |
+
type: string
|
| 79 |
+
enum:
|
| 80 |
+
- run_diagnostic
|
| 81 |
+
- update_route
|
| 82 |
+
- verify_routing
|
| 83 |
+
- throttle_bandwidth
|
| 84 |
+
- restore_replica
|
| 85 |
+
- promote_primary
|
| 86 |
+
- force_sync
|
| 87 |
+
- resolve_deadlock
|
| 88 |
+
- failover_region
|
| 89 |
+
- delegate
|
| 90 |
+
- noop
|
| 91 |
+
description: "The repair command to execute"
|
| 92 |
+
target_id:
|
| 93 |
+
type: string
|
| 94 |
+
description: "Target resource ID (e.g. dc2_router--dc3_router, replica_dc2)"
|
| 95 |
+
target_agent:
|
| 96 |
+
type: string
|
| 97 |
+
enum: [netops, dba]
|
| 98 |
+
description: "Sub-agent to delegate to (only for delegate action)"
|
| 99 |
+
instruction_payload:
|
| 100 |
+
type: object
|
| 101 |
+
description: "Nested action passed to the delegated sub-agent"
|
| 102 |
+
|
| 103 |
+
# ββ Tasks βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 104 |
+
tasks:
|
| 105 |
+
- id: partition_basic
|
| 106 |
+
description: >
|
| 107 |
+
THE PARTITION: A single network link between DC1 and DC2 has failed,
|
| 108 |
+
causing a split-brain warning. The agent must diagnose and restore the
|
| 109 |
+
link before replication lag becomes critical.
|
| 110 |
+
difficulty: basic
|
| 111 |
+
difficulty_score: 1
|
| 112 |
+
max_steps: 15
|
| 113 |
+
win_condition: "restore DC1βDC2 connectivity and verify routing"
|
| 114 |
+
expected_score: 1.0
|
| 115 |
+
|
| 116 |
+
- id: replication_storm
|
| 117 |
+
description: >
|
| 118 |
+
REPLICATION STORM: A thundering-herd replication burst has saturated
|
| 119 |
+
all inter-DC links. The agent must throttle bandwidth, restore replicas
|
| 120 |
+
in the correct order, and force a sync before lag exceeds the SLA.
|
| 121 |
+
difficulty: storm
|
| 122 |
+
difficulty_score: 2
|
| 123 |
+
max_steps: 25
|
| 124 |
+
win_condition: "throttle links, restore all replicas, force_sync"
|
| 125 |
+
expected_score: 1.0
|
| 126 |
+
|
| 127 |
+
- id: split_brain
|
| 128 |
+
description: >
|
| 129 |
+
THE SPLIT-BRAIN: Both DC1βDC2 and DC2βDC3 links are down simultaneously.
|
| 130 |
+
Two competing primaries have diverged. The agent must isolate one primary,
|
| 131 |
+
elect a canonical winner, and reconcile divergent write logs before data
|
| 132 |
+
loss becomes permanent.
|
| 133 |
+
difficulty: split
|
| 134 |
+
difficulty_score: 3
|
| 135 |
+
max_steps: 35
|
| 136 |
+
win_condition: "restore both links, promote single primary, reconcile replicas"
|
| 137 |
+
expected_score: 1.0
|
| 138 |
+
|
| 139 |
+
- id: cascading_deadlock
|
| 140 |
+
description: >
|
| 141 |
+
CASCADING DEADLOCK: A distributed transaction deadlock has propagated
|
| 142 |
+
across DC1 and DC2, freezing all write paths. Netops must re-route
|
| 143 |
+
traffic while DBA resolves the deadlock cycle. Incorrect ordering
|
| 144 |
+
(resolving before re-routing) re-triggers the deadlock.
|
| 145 |
+
difficulty: deadlock
|
| 146 |
+
difficulty_score: 4
|
| 147 |
+
max_steps: 35
|
| 148 |
+
win_condition: "update_route, verify_routing, resolve_deadlock, force_sync"
|
| 149 |
+
expected_score: 1.0
|
| 150 |
+
|
| 151 |
+
- id: regional_wipeout
|
| 152 |
+
description: >
|
| 153 |
+
REGIONAL WIPEOUT: DC3 has gone completely offline due to a catastrophic
|
| 154 |
+
hardware failure. DC1 and DC2 are in split-brain with diverged primaries.
|
| 155 |
+
The agent must execute a full regional failover: re-route all traffic,
|
| 156 |
+
promote a new primary, restore all replicas, and bring DC3 back online
|
| 157 |
+
β all within the step budget.
|
| 158 |
+
difficulty: wipeout
|
| 159 |
+
difficulty_score: 5
|
| 160 |
+
max_steps: 50
|
| 161 |
+
win_condition: "failover_region, promote_primary, restore all replicas, verify_routing"
|
| 162 |
+
expected_score: 1.0
|
| 163 |
+
|
| 164 |
+
# ββ Reward Design βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 165 |
+
reward_design:
|
| 166 |
+
type: shaped
|
| 167 |
+
description: >
|
| 168 |
+
Continuous shaped rewards provide signal across the full multi-agent trajectory.
|
| 169 |
+
Each correct delegation and sub-agent action yields partial credit (+0.1 to +0.5).
|
| 170 |
+
Incorrect ordering (e.g., promoting primary before routing is stable) yields
|
| 171 |
+
penalties (-0.1 to -0.3). Repeated run_diagnostic calls trigger diminishing
|
| 172 |
+
returns to prevent diagnostic loops. Episode terminates on task completion
|
| 173 |
+
(global_health >= 1.0) or max_steps timeout.
|
| 174 |
+
|
| 175 |
+
# ββ Baseline βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 176 |
+
baseline:
|
| 177 |
+
model: "deepseek-ai/DeepSeek-R1-Distill-Llama-70B"
|
| 178 |
+
script: "inference.py"
|
| 179 |
+
scores:
|
| 180 |
+
partition_basic: 1.0
|
| 181 |
+
replication_storm: 1.0
|
| 182 |
+
split_brain: 1.0
|
| 183 |
+
cascading_deadlock: 1.0
|
| 184 |
+
regional_wipeout: 1.0
|
| 185 |
+
|
| 186 |
+
# ββ Deployment ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 187 |
+
deployment:
|
| 188 |
+
platform: huggingface_spaces
|
| 189 |
+
space_id: "soonvalley04/openenv-split-brain-collapse"
|
| 190 |
+
sdk: docker
|
| 191 |
+
port: 7860
|
| 192 |
+
health_endpoint: "/health"
|
| 193 |
+
api_endpoints:
|
| 194 |
+
reset: "POST /reset"
|
| 195 |
+
step: "POST /step"
|
| 196 |
+
state: "GET /state"
|
pyproject.toml
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[build-system]
|
| 2 |
+
requires = ["setuptools>=61.0"]
|
| 3 |
+
build-backend = "setuptools.build_meta"
|
| 4 |
+
|
| 5 |
+
[project]
|
| 6 |
+
name = "cluster-triage-env"
|
| 7 |
+
version = "1.0.0"
|
| 8 |
+
description = "An OpenEnv-compliant RL environment simulating a 4-node enterprise data cluster."
|
| 9 |
+
readme = "README.md"
|
| 10 |
+
requires-python = ">=3.10"
|
| 11 |
+
dependencies = [
|
| 12 |
+
"fastapi==0.115.6",
|
| 13 |
+
"uvicorn[standard]==0.32.1",
|
| 14 |
+
"aiofiles>=23.0.0",
|
| 15 |
+
"requests>=2.31.0",
|
| 16 |
+
"pydantic==2.10.4",
|
| 17 |
+
"openai==1.58.1",
|
| 18 |
+
"python-dotenv==1.0.1",
|
| 19 |
+
"httpx==0.28.1",
|
| 20 |
+
"openenv-core<=0.2.0",
|
| 21 |
+
"networkx>=3.0"
|
| 22 |
+
]
|
| 23 |
+
|
| 24 |
+
[project.scripts]
|
| 25 |
+
server = "server.app:main"
|
requirements.txt
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Core web framework (serves both the OpenEnv RL API and HTML/CSS/JS UI)
|
| 2 |
+
fastapi==0.115.6
|
| 3 |
+
uvicorn[standard]==0.32.1
|
| 4 |
+
|
| 5 |
+
# Static file serving (replaces Gradio)
|
| 6 |
+
aiofiles>=23.0.0
|
| 7 |
+
|
| 8 |
+
# Pydantic (models, validation)
|
| 9 |
+
pydantic==2.10.4
|
| 10 |
+
|
| 11 |
+
# OpenAI client (for inference.py and /agent/step LLM calls)
|
| 12 |
+
openai==1.58.1
|
| 13 |
+
openenv-core<=0.2.0
|
| 14 |
+
|
| 15 |
+
# Environment variable loading
|
| 16 |
+
python-dotenv==1.0.1
|
| 17 |
+
|
| 18 |
+
# HTTP support for health checks in Docker
|
| 19 |
+
httpx==0.28.1
|
| 20 |
+
|
| 21 |
+
# HTTP support for openenv validate
|
| 22 |
+
requests>=2.31.0
|
| 23 |
+
|
| 24 |
+
# Graph algorithms for Split-Brain network topology pathfinding
|
| 25 |
+
networkx>=3.0
|
server/app.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
import os
|
| 3 |
+
import uvicorn
|
| 4 |
+
|
| 5 |
+
# Point back to the root directory so it can find your actual app
|
| 6 |
+
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 7 |
+
|
| 8 |
+
# Import the fully mounted FastAPI + Gradio app from the root app.py
|
| 9 |
+
from app import fastapi_app as app
|
| 10 |
+
|
| 11 |
+
def main():
|
| 12 |
+
"""Entry point required by the OpenEnv validator."""
|
| 13 |
+
uvicorn.run(app, host="0.0.0.0", port=7860, log_level="info")
|
| 14 |
+
|
| 15 |
+
if __name__ == "__main__":
|
| 16 |
+
main()
|
static/index.html
ADDED
|
@@ -0,0 +1,1351 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
| 7 |
+
<meta http-equiv="Pragma" content="no-cache">
|
| 8 |
+
<meta http-equiv="Expires" content="0">
|
| 9 |
+
<title>OpenEnv Β· Cluster SRE Command Center</title>
|
| 10 |
+
<meta name="description" content="OpenEnv Distributed Cluster Triage β Interactive RL environment where an AI agent acts as an SRE triaging infrastructure failures.">
|
| 11 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 12 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=IBM+Plex+Mono:wght@400;500;700&display=swap" rel="stylesheet">
|
| 13 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/cytoscape/3.28.1/cytoscape.min.js"></script>
|
| 14 |
+
<style>
|
| 15 |
+
/* ββ DESIGN TOKENS βββββββββββββββββββββββββββββββββββββββ */
|
| 16 |
+
:root {
|
| 17 |
+
--bg: #09090b;
|
| 18 |
+
--surface: #131316;
|
| 19 |
+
--elevated: #1c1c1f;
|
| 20 |
+
--border: #232328;
|
| 21 |
+
--border2: #3f3f46;
|
| 22 |
+
--accent: #8b5cf6;
|
| 23 |
+
--accent-h: #7c3aed;
|
| 24 |
+
--accent-glow: rgba(139,92,246,0.15);
|
| 25 |
+
--green: #10b981;
|
| 26 |
+
--red: #ef4444;
|
| 27 |
+
--yellow: #eab308;
|
| 28 |
+
--blue: #3b82f6;
|
| 29 |
+
--cyan: #22d3ee;
|
| 30 |
+
--text: #e4e4e7;
|
| 31 |
+
--muted: #71717a;
|
| 32 |
+
--subtle: #a1a1aa;
|
| 33 |
+
--mono: 'IBM Plex Mono', 'Courier New', monospace;
|
| 34 |
+
--sans: 'Inter', system-ui, sans-serif;
|
| 35 |
+
--r: 8px;
|
| 36 |
+
--r-lg: 12px;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
/* ββ RESET ββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 40 |
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
| 41 |
+
html { font-size: 14px; }
|
| 42 |
+
body {
|
| 43 |
+
background: var(--bg);
|
| 44 |
+
color: var(--text);
|
| 45 |
+
font-family: var(--sans);
|
| 46 |
+
min-height: 100vh;
|
| 47 |
+
overflow-x: hidden;
|
| 48 |
+
}
|
| 49 |
+
body::before {
|
| 50 |
+
content: '';
|
| 51 |
+
position: fixed; inset: 0;
|
| 52 |
+
pointer-events: none; z-index: 0;
|
| 53 |
+
background-image:
|
| 54 |
+
linear-gradient(rgba(139,92,246,0.02) 1px, transparent 1px),
|
| 55 |
+
linear-gradient(90deg, rgba(139,92,246,0.02) 1px, transparent 1px);
|
| 56 |
+
background-size: 48px 48px;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
/* ββ LAYOUT βββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 60 |
+
.app {
|
| 61 |
+
position: relative; z-index: 1;
|
| 62 |
+
max-width: 1480px; margin: 0 auto;
|
| 63 |
+
padding: 12px; display: flex;
|
| 64 |
+
flex-direction: column; gap: 10px;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
/* ββ CARDS ββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 68 |
+
.card {
|
| 69 |
+
background: var(--surface);
|
| 70 |
+
border: 1px solid var(--border);
|
| 71 |
+
border-radius: var(--r-lg);
|
| 72 |
+
overflow: hidden;
|
| 73 |
+
}
|
| 74 |
+
.card-header {
|
| 75 |
+
display: flex; align-items: center;
|
| 76 |
+
justify-content: space-between;
|
| 77 |
+
padding: 8px 12px;
|
| 78 |
+
border-bottom: 1px solid var(--border);
|
| 79 |
+
gap: 8px; min-height: 34px;
|
| 80 |
+
}
|
| 81 |
+
.card-title {
|
| 82 |
+
font-family: var(--mono);
|
| 83 |
+
font-size: 9px; font-weight: 700;
|
| 84 |
+
color: var(--muted);
|
| 85 |
+
letter-spacing: 2px; text-transform: uppercase;
|
| 86 |
+
}
|
| 87 |
+
.card-body { padding: 10px; }
|
| 88 |
+
|
| 89 |
+
/* ββ HEADER βββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 90 |
+
.header {
|
| 91 |
+
display: flex; align-items: center;
|
| 92 |
+
justify-content: space-between;
|
| 93 |
+
padding: 9px 16px;
|
| 94 |
+
background: var(--surface);
|
| 95 |
+
border: 1px solid var(--border);
|
| 96 |
+
border-radius: var(--r-lg);
|
| 97 |
+
gap: 12px; flex-wrap: wrap;
|
| 98 |
+
}
|
| 99 |
+
.logo { display: flex; align-items: baseline; gap: 8px; }
|
| 100 |
+
.logo-name {
|
| 101 |
+
font-family: var(--mono); font-size: 16px; font-weight: 700;
|
| 102 |
+
background: linear-gradient(135deg, var(--accent), #a78bfa);
|
| 103 |
+
-webkit-background-clip: text; background-clip: text;
|
| 104 |
+
-webkit-text-fill-color: transparent;
|
| 105 |
+
}
|
| 106 |
+
.logo-sub { font-family: var(--mono); font-size: 9px; color: var(--muted); letter-spacing: 1px; }
|
| 107 |
+
.header-right { display: flex; align-items: center; gap: 8px; }
|
| 108 |
+
.status-badge {
|
| 109 |
+
display: flex; align-items: center; gap: 6px;
|
| 110 |
+
padding: 4px 10px;
|
| 111 |
+
background: #0a0a0d;
|
| 112 |
+
border: 1px solid var(--border);
|
| 113 |
+
border-radius: 100px;
|
| 114 |
+
font-family: var(--mono); font-size: 9px; color: var(--muted);
|
| 115 |
+
}
|
| 116 |
+
.status-dot {
|
| 117 |
+
width: 6px; height: 6px; border-radius: 50%;
|
| 118 |
+
background: var(--muted); flex-shrink: 0;
|
| 119 |
+
}
|
| 120 |
+
.status-dot.live { background: var(--green); animation: pulse-dot 2s infinite; }
|
| 121 |
+
.status-dot.err { background: var(--red); }
|
| 122 |
+
@keyframes pulse-dot {
|
| 123 |
+
0%, 100% { box-shadow: 0 0 0 0 rgba(16,185,129,0.4); }
|
| 124 |
+
50% { box-shadow: 0 0 0 4px rgba(16,185,129,0); }
|
| 125 |
+
}
|
| 126 |
+
.version-tag {
|
| 127 |
+
font-family: var(--mono); font-size: 9px; color: var(--accent);
|
| 128 |
+
background: rgba(139,92,246,0.1);
|
| 129 |
+
border: 1px solid rgba(139,92,246,0.25);
|
| 130 |
+
padding: 2px 8px; border-radius: 100px;
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
/* ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 134 |
+
MAIN 3-COL GRID
|
| 135 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 136 |
+
.main-grid {
|
| 137 |
+
display: grid;
|
| 138 |
+
grid-template-columns: 220px 1fr 210px;
|
| 139 |
+
gap: 10px; align-items: start;
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
/* ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 143 |
+
LEFT SIDEBAR: AGENTS β TASKS
|
| 144 |
+
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 145 |
+
Auto-populated from GET /agents.
|
| 146 |
+
Each agent is collapsible; clicking expands its task list.
|
| 147 |
+
EXTENSIBILITY: add agents to agents/__init__.py β auto-appears.
|
| 148 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 149 |
+
.sidebar-inner { display: flex; flex-direction: column; gap: 8px; }
|
| 150 |
+
|
| 151 |
+
/* Agent group = agent header + collapsible task list */
|
| 152 |
+
.agent-group { margin-bottom: 2px; }
|
| 153 |
+
.agent-header {
|
| 154 |
+
display: flex; align-items: center; gap: 8px;
|
| 155 |
+
padding: 9px 10px;
|
| 156 |
+
border: 1px solid var(--border);
|
| 157 |
+
border-radius: var(--r);
|
| 158 |
+
cursor: pointer; transition: all 0.15s;
|
| 159 |
+
background: #0e0e11;
|
| 160 |
+
user-select: none;
|
| 161 |
+
}
|
| 162 |
+
.agent-header:hover { border-color: var(--border2); background: #141417; }
|
| 163 |
+
.agent-header.active {
|
| 164 |
+
border-color: var(--accent);
|
| 165 |
+
background: rgba(139,92,246,0.06);
|
| 166 |
+
box-shadow: 0 0 8px var(--accent-glow);
|
| 167 |
+
}
|
| 168 |
+
.agent-icon { font-size: 16px; flex-shrink: 0; }
|
| 169 |
+
.agent-header-text { flex: 1; min-width: 0; }
|
| 170 |
+
.agent-name { font-size: 12px; font-weight: 700; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
| 171 |
+
.agent-desc-mini { font-family: var(--mono); font-size: 8px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 1px; }
|
| 172 |
+
.agent-chevron {
|
| 173 |
+
font-family: var(--mono); font-size: 10px; color: var(--muted);
|
| 174 |
+
transition: transform 0.2s;
|
| 175 |
+
}
|
| 176 |
+
.agent-header.active .agent-chevron { transform: rotate(90deg); color: var(--accent); }
|
| 177 |
+
|
| 178 |
+
/* Task list inside agent β hidden until agent is expanded */
|
| 179 |
+
.task-list {
|
| 180 |
+
max-height: 0; overflow: hidden;
|
| 181 |
+
transition: max-height 0.3s ease;
|
| 182 |
+
padding-left: 12px;
|
| 183 |
+
}
|
| 184 |
+
.task-list.open { max-height: 500px; }
|
| 185 |
+
.task-list-inner { padding: 4px 0 2px; display: flex; flex-direction: column; gap: 3px; }
|
| 186 |
+
|
| 187 |
+
.task-item {
|
| 188 |
+
display: flex; align-items: center; gap: 6px;
|
| 189 |
+
padding: 6px 9px;
|
| 190 |
+
border: 1px solid transparent;
|
| 191 |
+
border-radius: 6px;
|
| 192 |
+
cursor: pointer; transition: all 0.12s;
|
| 193 |
+
background: transparent;
|
| 194 |
+
}
|
| 195 |
+
.task-item:hover { background: #141417; border-color: var(--border); }
|
| 196 |
+
.task-item.selected {
|
| 197 |
+
background: rgba(139,92,246,0.08);
|
| 198 |
+
border-color: rgba(139,92,246,0.3);
|
| 199 |
+
}
|
| 200 |
+
.task-dot {
|
| 201 |
+
width: 6px; height: 6px;
|
| 202 |
+
border-radius: 50%; flex-shrink: 0;
|
| 203 |
+
}
|
| 204 |
+
.task-item-name { font-size: 11px; color: var(--text); flex: 1; }
|
| 205 |
+
.task-item-diff {
|
| 206 |
+
font-family: var(--mono); font-size: 8px; font-weight: 700;
|
| 207 |
+
padding: 1px 5px; border-radius: 3px;
|
| 208 |
+
letter-spacing: 0.5px;
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
/* "Add Agent" placeholder β bottom of sidebar */
|
| 212 |
+
.add-agent-slot {
|
| 213 |
+
display: flex; align-items: center; gap: 8px;
|
| 214 |
+
padding: 9px 10px;
|
| 215 |
+
border: 1px dashed var(--border2);
|
| 216 |
+
border-radius: var(--r);
|
| 217 |
+
opacity: 0.4; cursor: default;
|
| 218 |
+
margin-top: 2px;
|
| 219 |
+
}
|
| 220 |
+
.add-agent-icon { font-size: 14px; color: var(--muted); }
|
| 221 |
+
.add-agent-text { font-family: var(--mono); font-size: 8px; color: var(--muted); letter-spacing: 1px; line-height: 1.5; }
|
| 222 |
+
|
| 223 |
+
/* ββ CONTROL BUTTONS ββββββββββββββββββββββββββββββββββββββ */
|
| 224 |
+
.ctrl-btns { display: flex; flex-direction: column; gap: 5px; margin-top: 8px; }
|
| 225 |
+
.btn {
|
| 226 |
+
display: flex; align-items: center; justify-content: center; gap: 5px;
|
| 227 |
+
padding: 8px 12px; border-radius: var(--r); border: none;
|
| 228 |
+
font-family: var(--mono); font-size: 9px;
|
| 229 |
+
font-weight: 700; letter-spacing: 1px;
|
| 230 |
+
cursor: pointer; transition: all 0.15s;
|
| 231 |
+
text-transform: uppercase; width: 100%;
|
| 232 |
+
}
|
| 233 |
+
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
| 234 |
+
.btn-primary { background: var(--accent); color: #fff; }
|
| 235 |
+
.btn-primary:hover:not(:disabled) {
|
| 236 |
+
background: var(--accent-h); transform: translateY(-1px);
|
| 237 |
+
box-shadow: 0 4px 12px rgba(139,92,246,0.4);
|
| 238 |
+
}
|
| 239 |
+
.btn-ghost { background: #0e0e11; color: var(--subtle); border: 1px solid var(--border); }
|
| 240 |
+
.btn-ghost:hover:not(:disabled) { border-color: var(--border2); color: var(--text); }
|
| 241 |
+
.spinner {
|
| 242 |
+
width: 11px; height: 11px;
|
| 243 |
+
border: 2px solid rgba(255,255,255,0.2); border-top-color: #fff;
|
| 244 |
+
border-radius: 50%; animation: spin 0.7s linear infinite; display: none;
|
| 245 |
+
}
|
| 246 |
+
.spinner.on { display: block; }
|
| 247 |
+
@keyframes spin { to { transform: rotate(360deg); } }
|
| 248 |
+
|
| 249 |
+
/* ββ CENTER PANEL ββββββββββββββββββββββββββββββββββββββββββ */
|
| 250 |
+
.center-panel { display: flex; flex-direction: column; gap: 10px; }
|
| 251 |
+
.briefing-text {
|
| 252 |
+
font-family: var(--mono); font-size: 11px;
|
| 253 |
+
color: var(--subtle); line-height: 1.8;
|
| 254 |
+
white-space: pre-wrap; padding: 10px;
|
| 255 |
+
background: #0a0a0d; border-radius: var(--r);
|
| 256 |
+
min-height: 80px;
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
/* ββ NODE GRID βββββββββββββββββββββββββββββββββββββββββββββ */
|
| 260 |
+
.node-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
|
| 261 |
+
.node-card {
|
| 262 |
+
background: #0a0a0d;
|
| 263 |
+
border: 1px solid var(--border);
|
| 264 |
+
border-radius: var(--r);
|
| 265 |
+
padding: 8px 10px;
|
| 266 |
+
border-left-width: 3px;
|
| 267 |
+
transition: border-color 0.4s;
|
| 268 |
+
}
|
| 269 |
+
.node-card.healthy { border-left-color: var(--green); }
|
| 270 |
+
.node-card.offline { border-left-color: var(--red); }
|
| 271 |
+
.node-card.high_memory { border-left-color: var(--yellow); }
|
| 272 |
+
.node-card.disk_full { border-left-color: var(--yellow); }
|
| 273 |
+
.node-card.unknown { border-left-color: var(--border2);}
|
| 274 |
+
.node-id { font-family: var(--mono); font-size: 11px; font-weight: 700; }
|
| 275 |
+
.node-status {
|
| 276 |
+
font-family: var(--mono); font-size: 8px; font-weight: 700;
|
| 277 |
+
text-transform: uppercase; letter-spacing: 1px; margin: 2px 0 5px;
|
| 278 |
+
}
|
| 279 |
+
.node-status.healthy { color: var(--green); }
|
| 280 |
+
.node-status.offline { color: var(--red); }
|
| 281 |
+
.node-status.high_memory { color: var(--yellow); }
|
| 282 |
+
.node-status.disk_full { color: var(--yellow); }
|
| 283 |
+
.node-status.unknown { color: var(--muted); }
|
| 284 |
+
.node-metrics {
|
| 285 |
+
display: flex; justify-content: space-between;
|
| 286 |
+
font-family: var(--mono); font-size: 9px; color: var(--muted);
|
| 287 |
+
}
|
| 288 |
+
.metric-bar { height: 2px; background: var(--border); border-radius: 2px; margin-top: 4px; overflow: hidden; }
|
| 289 |
+
.metric-bar-fill { height: 100%; border-radius: 2px; transition: width 0.5s ease; }
|
| 290 |
+
|
| 291 |
+
/* ββ SCORE PANEL (right col) βββββββββββββββββββββββββββββββ */
|
| 292 |
+
.score-number {
|
| 293 |
+
font-family: var(--mono); font-size: 44px;
|
| 294 |
+
font-weight: 700; line-height: 1;
|
| 295 |
+
text-align: center; transition: color 0.3s;
|
| 296 |
+
}
|
| 297 |
+
.score-label {
|
| 298 |
+
font-family: var(--mono); font-size: 8px;
|
| 299 |
+
color: var(--muted); letter-spacing: 2px;
|
| 300 |
+
text-align: center; text-transform: uppercase; margin-top: 4px;
|
| 301 |
+
}
|
| 302 |
+
.score-divider { border: none; border-top: 1px solid var(--border); margin: 8px 0; }
|
| 303 |
+
.score-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 5px; }
|
| 304 |
+
.score-row-lbl { font-family: var(--mono); font-size: 9px; color: var(--muted); text-transform: uppercase; letter-spacing: 1px; }
|
| 305 |
+
.score-row-val { font-family: var(--mono); font-size: 15px; font-weight: 700; color: var(--green); }
|
| 306 |
+
.reward-bars {
|
| 307 |
+
display: flex; align-items: flex-end; height: 45px; gap: 3px;
|
| 308 |
+
background: rgba(0,0,0,0.3); border-radius: 5px 5px 0 0; padding: 6px 6px 0;
|
| 309 |
+
}
|
| 310 |
+
.rbar { flex: 1; border-radius: 2px 2px 0 0; opacity: 0.85; min-height: 3px; transition: height 0.3s; }
|
| 311 |
+
.rbar.pos { background: var(--green); } .rbar.neg { background: var(--red); }
|
| 312 |
+
.rbar-axis { display: flex; justify-content: space-between; font-family: var(--mono); font-size: 8px; color: var(--muted); margin-top: 3px; }
|
| 313 |
+
|
| 314 |
+
/* ββ TERMINAL ββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 315 |
+
.terminal {
|
| 316 |
+
background: #050507; border-radius: var(--r);
|
| 317 |
+
padding: 10px 12px; height: 220px;
|
| 318 |
+
overflow-y: auto;
|
| 319 |
+
font-family: var(--mono); font-size: 11px; line-height: 1.8;
|
| 320 |
+
border: 1px solid #1a1a1f;
|
| 321 |
+
}
|
| 322 |
+
.terminal::-webkit-scrollbar { width: 3px; }
|
| 323 |
+
.terminal::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 2px; }
|
| 324 |
+
.t-sys { color: var(--muted); }
|
| 325 |
+
.t-reset { color: #a78bfa; font-weight: 500; }
|
| 326 |
+
.t-step { color: var(--text); }
|
| 327 |
+
.t-rpos { color: var(--green); font-weight: 700; }
|
| 328 |
+
.t-rneg { color: var(--red); font-weight: 700; }
|
| 329 |
+
.t-ok { color: var(--green); }
|
| 330 |
+
.t-warn { color: var(--yellow); }
|
| 331 |
+
.t-err { color: var(--red); }
|
| 332 |
+
.t-info { color: var(--blue); }
|
| 333 |
+
|
| 334 |
+
/* ββ BOTTOM GRID ββββββββββββββββββββββββββββββββββββββββββββ */
|
| 335 |
+
.bottom-grid { display: grid; grid-template-columns: 1fr 310px; gap: 10px; }
|
| 336 |
+
.api-pills { display: flex; flex-wrap: wrap; gap: 5px; margin-bottom: 8px; }
|
| 337 |
+
.api-pill {
|
| 338 |
+
display: flex; align-items: center; gap: 3px;
|
| 339 |
+
padding: 3px 8px; background: #0a0a0d;
|
| 340 |
+
border: 1px solid var(--border); border-radius: 100px;
|
| 341 |
+
font-family: var(--mono); font-size: 8px; color: var(--muted);
|
| 342 |
+
cursor: pointer; transition: all 0.15s;
|
| 343 |
+
}
|
| 344 |
+
.api-pill:hover { border-color: var(--accent); color: var(--accent); }
|
| 345 |
+
.m-get { color: var(--green); font-weight: 700; }
|
| 346 |
+
.m-post { color: var(--cyan); font-weight: 700; }
|
| 347 |
+
.api-output {
|
| 348 |
+
background: #050507; border: 1px solid var(--border); border-radius: var(--r);
|
| 349 |
+
padding: 8px 10px; font-family: var(--mono); font-size: 9px;
|
| 350 |
+
color: #88aacc; line-height: 1.7; white-space: pre-wrap; max-height: 100px; overflow-y: auto;
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
/* ββ UTILITY ββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 354 |
+
.tag {
|
| 355 |
+
font-family: var(--mono); font-size: 8px; color: var(--muted);
|
| 356 |
+
background: rgba(255,255,255,0.04);
|
| 357 |
+
border: 1px solid var(--border);
|
| 358 |
+
padding: 2px 6px; border-radius: 4px;
|
| 359 |
+
}
|
| 360 |
+
.flex-row { display: flex; align-items: center; gap: 6px; }
|
| 361 |
+
.footer {
|
| 362 |
+
display: flex; justify-content: space-between; align-items: center;
|
| 363 |
+
padding: 6px 4px; font-family: var(--mono); font-size: 8px;
|
| 364 |
+
color: var(--muted); flex-wrap: wrap; gap: 4px;
|
| 365 |
+
}
|
| 366 |
+
@keyframes fadeIn {
|
| 367 |
+
from { opacity: 0; transform: translateY(3px); }
|
| 368 |
+
to { opacity: 1; transform: translateY(0); }
|
| 369 |
+
}
|
| 370 |
+
.fade-in { animation: fadeIn 0.2s ease forwards; }
|
| 371 |
+
|
| 372 |
+
/* ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 373 |
+
SPLIT-BRAIN WAR ROOM STYLES
|
| 374 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 375 |
+
.warroom { display: none; }
|
| 376 |
+
.warroom.active { display: grid; grid-template-columns: 280px 1fr 240px; gap: 10px; align-items: start; }
|
| 377 |
+
|
| 378 |
+
/* War Room Chat Log */
|
| 379 |
+
.wr-chat { height: 420px; overflow-y: auto; padding: 10px; display: flex; flex-direction: column; gap: 6px; }
|
| 380 |
+
.wr-chat::-webkit-scrollbar { width: 3px; }
|
| 381 |
+
.wr-chat::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 2px; }
|
| 382 |
+
.wr-msg { display: flex; gap: 8px; padding: 8px 10px; border-radius: var(--r); background: #0a0a0d; border: 1px solid var(--border); animation: fadeIn 0.3s ease; }
|
| 383 |
+
.wr-msg-avatar { width: 28px; height: 28px; border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 14px; flex-shrink: 0; }
|
| 384 |
+
.wr-msg-body { flex: 1; min-width: 0; }
|
| 385 |
+
.wr-msg-name { font-family: var(--mono); font-size: 9px; font-weight: 700; letter-spacing: 1px; text-transform: uppercase; margin-bottom: 2px; }
|
| 386 |
+
.wr-msg-text { font-family: var(--mono); font-size: 10px; color: var(--subtle); line-height: 1.6; word-break: break-word; }
|
| 387 |
+
.wr-orch .wr-msg-avatar { background: rgba(139,92,246,0.2); }
|
| 388 |
+
.wr-orch .wr-msg-name { color: #a78bfa; }
|
| 389 |
+
.wr-netops .wr-msg-avatar { background: rgba(59,130,246,0.2); }
|
| 390 |
+
.wr-netops .wr-msg-name { color: #60a5fa; }
|
| 391 |
+
.wr-dataops .wr-msg-avatar { background: rgba(16,185,129,0.2); }
|
| 392 |
+
.wr-dataops .wr-msg-name { color: #34d399; }
|
| 393 |
+
.wr-system .wr-msg-avatar { background: rgba(113,113,122,0.2); }
|
| 394 |
+
.wr-system .wr-msg-name { color: var(--muted); }
|
| 395 |
+
|
| 396 |
+
/* Topology Graph */
|
| 397 |
+
#cyGraph { width: 100%; height: 420px; background: #050507; border-radius: var(--r); border: 1px solid var(--border); }
|
| 398 |
+
|
| 399 |
+
/* Telemetry Gauges */
|
| 400 |
+
.telem-gauge { margin-bottom: 12px; }
|
| 401 |
+
.telem-label { font-family: var(--mono); font-size: 8px; color: var(--muted); text-transform: uppercase; letter-spacing: 1.5px; margin-bottom: 4px; }
|
| 402 |
+
.telem-val { font-family: var(--mono); font-size: 22px; font-weight: 700; line-height: 1.2; }
|
| 403 |
+
.telem-bar { height: 6px; background: var(--border); border-radius: 3px; margin-top: 4px; overflow: hidden; }
|
| 404 |
+
.telem-bar-fill { height: 100%; border-radius: 3px; transition: width 0.5s ease, background 0.5s ease; }
|
| 405 |
+
.telem-sub { font-family: var(--mono); font-size: 8px; color: var(--muted); margin-top: 3px; }
|
| 406 |
+
|
| 407 |
+
/* Actor Badge */
|
| 408 |
+
.actor-badge { display: inline-flex; align-items: center; gap: 5px; padding: 4px 10px; border-radius: 100px; font-family: var(--mono); font-size: 9px; font-weight: 700; letter-spacing: 1px; text-transform: uppercase; }
|
| 409 |
+
.actor-badge.orchestrator { background: rgba(139,92,246,0.15); color: #a78bfa; border: 1px solid rgba(139,92,246,0.3); }
|
| 410 |
+
.actor-badge.netops { background: rgba(59,130,246,0.15); color: #60a5fa; border: 1px solid rgba(59,130,246,0.3); }
|
| 411 |
+
.actor-badge.dataops { background: rgba(16,185,129,0.15); color: #34d399; border: 1px solid rgba(16,185,129,0.3); }
|
| 412 |
+
|
| 413 |
+
/* ββ RESPONSIVE ββββββββββββββββββββββββββββββββββββββββββββ */
|
| 414 |
+
@media (max-width: 1100px) { .main-grid { grid-template-columns: 200px 1fr 195px; } .warroom.active { grid-template-columns: 1fr; } }
|
| 415 |
+
@media (max-width: 900px) { .main-grid { grid-template-columns: 1fr; } .bottom-grid { grid-template-columns: 1fr; } .warroom.active { grid-template-columns: 1fr; } }
|
| 416 |
+
</style>
|
| 417 |
+
</head>
|
| 418 |
+
<body>
|
| 419 |
+
<div class="app">
|
| 420 |
+
|
| 421 |
+
<!-- ββ HEADER ββ -->
|
| 422 |
+
<header class="header" id="appHeader">
|
| 423 |
+
<div class="logo">
|
| 424 |
+
<span class="logo-name">OpenEnv</span>
|
| 425 |
+
<span class="logo-sub">Cluster SRE Β· Distributed Triage</span>
|
| 426 |
+
</div>
|
| 427 |
+
<div class="header-right">
|
| 428 |
+
<div class="status-badge">
|
| 429 |
+
<div class="status-dot" id="statusDot"></div>
|
| 430 |
+
<span id="statusText">Connecting...</span>
|
| 431 |
+
</div>
|
| 432 |
+
<span class="version-tag" id="versionTag">v1.0.0</span>
|
| 433 |
+
</div>
|
| 434 |
+
</header>
|
| 435 |
+
|
| 436 |
+
<!-- βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 437 |
+
MAIN 3-COLUMN GRID
|
| 438 |
+
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ -->
|
| 439 |
+
<div class="main-grid">
|
| 440 |
+
|
| 441 |
+
<!-- ββ COL 1: AGENTS SIDEBAR ββββββββββββββββββββββββββββ -->
|
| 442 |
+
<div class="card" id="sidebarCard">
|
| 443 |
+
<div class="card-header">
|
| 444 |
+
<span class="card-title">π€ Agents</span>
|
| 445 |
+
<span class="tag" id="agentCountTag">Loading</span>
|
| 446 |
+
</div>
|
| 447 |
+
<div class="card-body">
|
| 448 |
+
<div class="sidebar-inner" id="sidebarAgents">
|
| 449 |
+
<div style="font-family:var(--mono);font-size:10px;color:var(--muted);padding:8px;">Discovering agents...</div>
|
| 450 |
+
</div>
|
| 451 |
+
|
| 452 |
+
<!-- Controls -->
|
| 453 |
+
<div class="ctrl-btns">
|
| 454 |
+
<button class="btn btn-ghost" id="btnState" onclick="doState()">π State</button>
|
| 455 |
+
<button class="btn btn-ghost" id="btnReset" onclick="doReset()">π Reset</button>
|
| 456 |
+
<button class="btn btn-primary" id="btnStep" onclick="doAgentStep()">
|
| 457 |
+
<div class="spinner" id="stepSpinner"></div>
|
| 458 |
+
<span id="stepBtnTxt">βΆ Agent Step</span>
|
| 459 |
+
</button>
|
| 460 |
+
</div>
|
| 461 |
+
</div>
|
| 462 |
+
</div>
|
| 463 |
+
|
| 464 |
+
<!-- ββ COL 2: BRIEFING + NODES ββββββββββββββββββββββββββ -->
|
| 465 |
+
<div class="center-panel" id="centerPanel">
|
| 466 |
+
<div class="card">
|
| 467 |
+
<div class="card-header">
|
| 468 |
+
<span class="card-title">π Active Incident Briefing</span>
|
| 469 |
+
<div class="flex-row">
|
| 470 |
+
<span class="tag" id="stepCounter">Step 0</span>
|
| 471 |
+
<span class="tag" id="healthTag">Health: β</span>
|
| 472 |
+
</div>
|
| 473 |
+
</div>
|
| 474 |
+
<div class="card-body" style="padding:0;">
|
| 475 |
+
<div class="briefing-text" id="briefingText">Select an agent and task from the sidebar, then click π Reset...</div>
|
| 476 |
+
</div>
|
| 477 |
+
</div>
|
| 478 |
+
<div class="card">
|
| 479 |
+
<div class="card-header">
|
| 480 |
+
<span class="card-title">π₯οΈ Live Node Telemetry</span>
|
| 481 |
+
<span id="nodeSub" style="font-family:var(--mono);font-size:8px;color:var(--muted);">4 nodes Β· awaiting reset</span>
|
| 482 |
+
</div>
|
| 483 |
+
<div class="card-body">
|
| 484 |
+
<div class="node-grid" id="nodeGrid">
|
| 485 |
+
<div class="node-card unknown"><div class="node-id">worker_01</div><div class="node-status unknown">standby</div><div class="node-metrics"><span>Disk β</span><span>RAM β</span></div></div>
|
| 486 |
+
<div class="node-card unknown"><div class="node-id">worker_02</div><div class="node-status unknown">standby</div><div class="node-metrics"><span>Disk β</span><span>RAM β</span></div></div>
|
| 487 |
+
<div class="node-card unknown"><div class="node-id">worker_03</div><div class="node-status unknown">standby</div><div class="node-metrics"><span>Disk β</span><span>RAM β</span></div></div>
|
| 488 |
+
<div class="node-card unknown"><div class="node-id">worker_04</div><div class="node-status unknown">standby</div><div class="node-metrics"><span>Disk β</span><span>RAM β</span></div></div>
|
| 489 |
+
</div>
|
| 490 |
+
</div>
|
| 491 |
+
</div>
|
| 492 |
+
</div>
|
| 493 |
+
|
| 494 |
+
<!-- ββ COL 3: EPISODE SCORE βββββββββββββββββββββββββββββ -->
|
| 495 |
+
<div class="card" id="scoreCard">
|
| 496 |
+
<div class="card-header">
|
| 497 |
+
<span class="card-title">π Score</span>
|
| 498 |
+
<span id="doneTag" style="display:none;font-family:var(--mono);font-size:8px;color:var(--green);font-weight:700;letter-spacing:1px;">β DONE</span>
|
| 499 |
+
</div>
|
| 500 |
+
<div class="card-body">
|
| 501 |
+
<div class="score-number" id="scoreReward" style="color:var(--muted);">+0.000</div>
|
| 502 |
+
<div class="score-label">Step Reward</div>
|
| 503 |
+
<hr class="score-divider">
|
| 504 |
+
<div class="score-row">
|
| 505 |
+
<span class="score-row-lbl">Health</span>
|
| 506 |
+
<span class="score-row-val" id="scoreHealth">β</span>
|
| 507 |
+
</div>
|
| 508 |
+
<div class="score-row">
|
| 509 |
+
<span class="score-row-lbl">Total</span>
|
| 510 |
+
<span style="font-family:var(--mono);font-size:14px;font-weight:700;color:var(--subtle);" id="scoreTotal">0.000</span>
|
| 511 |
+
</div>
|
| 512 |
+
<hr class="score-divider">
|
| 513 |
+
<div style="font-family:var(--mono);font-size:8px;color:var(--muted);letter-spacing:2px;text-transform:uppercase;margin-bottom:6px;">Reward History</div>
|
| 514 |
+
<div class="reward-bars" id="rewardBars">
|
| 515 |
+
<div style="font-family:var(--mono);font-size:8px;color:var(--muted);margin:auto;">No steps yet</div>
|
| 516 |
+
</div>
|
| 517 |
+
<div class="rbar-axis"><span>+1.0</span><span id="avgRwd">avg β</span><span>-1.0</span></div>
|
| 518 |
+
<div style="font-family:var(--mono);font-size:9px;color:var(--muted);text-align:center;margin-top:6px;" id="stepCounterScore">0 / β steps</div>
|
| 519 |
+
</div>
|
| 520 |
+
</div>
|
| 521 |
+
|
| 522 |
+
|
| 523 |
+
<!-- βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 524 |
+
SPLIT-BRAIN WAR ROOM (hidden until split_brain agent selected)
|
| 525 |
+
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ -->
|
| 526 |
+
<div class="warroom" id="warroom">
|
| 527 |
+
<!-- LEFT: Agent Chat Log -->
|
| 528 |
+
<div class="card">
|
| 529 |
+
<div class="card-header">
|
| 530 |
+
<span class="card-title">π¬ War Room</span>
|
| 531 |
+
<span class="actor-badge orchestrator" id="actorBadge">π― Orchestrator</span>
|
| 532 |
+
</div>
|
| 533 |
+
<div class="card-body" style="padding:0;">
|
| 534 |
+
<div class="wr-chat" id="wrChat">
|
| 535 |
+
<div class="wr-msg wr-system"><div class="wr-msg-avatar">β‘</div><div class="wr-msg-body"><div class="wr-msg-name">System</div><div class="wr-msg-text">War room initialized. Waiting for incident...</div></div></div>
|
| 536 |
+
</div>
|
| 537 |
+
</div>
|
| 538 |
+
</div>
|
| 539 |
+
<!-- CENTER: Topology Graph -->
|
| 540 |
+
<div class="card">
|
| 541 |
+
<div class="card-header">
|
| 542 |
+
<span class="card-title">π Network Topology</span>
|
| 543 |
+
<span class="tag" id="topoStatus">Awaiting reset</span>
|
| 544 |
+
</div>
|
| 545 |
+
<div class="card-body" style="padding:6px;">
|
| 546 |
+
<div id="cyGraph"></div>
|
| 547 |
+
</div>
|
| 548 |
+
</div>
|
| 549 |
+
<!-- RIGHT: Telemetry -->
|
| 550 |
+
<div class="card">
|
| 551 |
+
<div class="card-header">
|
| 552 |
+
<span class="card-title">π Telemetry</span>
|
| 553 |
+
</div>
|
| 554 |
+
<div class="card-body">
|
| 555 |
+
<div class="telem-gauge">
|
| 556 |
+
<div class="telem-label">Global Health</div>
|
| 557 |
+
<div class="telem-val" id="tmHealth" style="color:var(--red);">0%</div>
|
| 558 |
+
<div class="telem-bar"><div class="telem-bar-fill" id="tmHealthBar" style="width:0%;background:var(--red);"></div></div>
|
| 559 |
+
</div>
|
| 560 |
+
<div class="telem-gauge">
|
| 561 |
+
<div class="telem-label">HDFS I/O Bandwidth</div>
|
| 562 |
+
<div class="telem-val" id="tmIO" style="color:var(--red);">100%</div>
|
| 563 |
+
<div class="telem-bar"><div class="telem-bar-fill" id="tmIOBar" style="width:100%;background:var(--red);"></div></div>
|
| 564 |
+
<div class="telem-sub" id="tmStorm">β REPLICATION STORM</div>
|
| 565 |
+
</div>
|
| 566 |
+
<div class="telem-gauge">
|
| 567 |
+
<div class="telem-label">NewSQL Ledger Drift</div>
|
| 568 |
+
<div class="telem-val" id="tmDrift" style="color:var(--red);">15</div>
|
| 569 |
+
<div class="telem-bar"><div class="telem-bar-fill" id="tmDriftBar" style="width:100%;background:var(--red);"></div></div>
|
| 570 |
+
<div class="telem-sub" id="tmBrain">β SPLIT-BRAIN ACTIVE</div>
|
| 571 |
+
</div>
|
| 572 |
+
<div class="telem-gauge">
|
| 573 |
+
<div class="telem-label">Network Status</div>
|
| 574 |
+
<div class="telem-val" id="tmNet" style="color:var(--red);">PARTITIONED</div>
|
| 575 |
+
<div class="telem-sub" id="tmRouting">Routing: unverified</div>
|
| 576 |
+
</div>
|
| 577 |
+
<div class="telem-gauge">
|
| 578 |
+
<div class="telem-label">Redis Cache Usage</div>
|
| 579 |
+
<div class="telem-val" id="tmRedis" style="color:var(--green);">0%</div>
|
| 580 |
+
<div class="telem-bar"><div class="telem-bar-fill" id="tmRedisBar" style="width:0%;background:var(--green);"></div></div>
|
| 581 |
+
<div class="telem-sub" id="tmAuth">Auth: online</div>
|
| 582 |
+
</div>
|
| 583 |
+
<hr class="score-divider">
|
| 584 |
+
<div class="telem-gauge">
|
| 585 |
+
<div class="telem-label">Reward This Step</div>
|
| 586 |
+
<div class="telem-val" id="tmReward" style="color:var(--muted);">+0.00</div>
|
| 587 |
+
</div>
|
| 588 |
+
<div class="telem-gauge">
|
| 589 |
+
<div class="telem-label">Total Reward</div>
|
| 590 |
+
<div class="telem-val" id="tmTotal" style="color:var(--subtle);">0.00</div>
|
| 591 |
+
</div>
|
| 592 |
+
</div>
|
| 593 |
+
</div>
|
| 594 |
+
</div><!-- /.warroom -->
|
| 595 |
+
</div><!-- /.main-grid -->
|
| 596 |
+
|
| 597 |
+
<!-- βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 598 |
+
BOTTOM GRID: TERMINAL + SIDE PANELS
|
| 599 |
+
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ -->
|
| 600 |
+
<div class="bottom-grid">
|
| 601 |
+
<div class="card">
|
| 602 |
+
<div class="card-header">
|
| 603 |
+
<span class="card-title">π» Agent Terminal</span>
|
| 604 |
+
<div class="flex-row">
|
| 605 |
+
<div class="spinner" id="termSpinner"></div>
|
| 606 |
+
<span id="llmTag" style="font-family:var(--mono);font-size:8px;color:var(--accent);display:none;letter-spacing:1px;">LLM THINKING...</span>
|
| 607 |
+
<button class="btn btn-ghost" style="padding:3px 8px;font-size:8px;width:auto;" onclick="clearTerminal()">CLEAR</button>
|
| 608 |
+
</div>
|
| 609 |
+
</div>
|
| 610 |
+
<div class="card-body" style="padding:0 10px 10px;">
|
| 611 |
+
<div class="terminal" id="terminal">
|
| 612 |
+
<div class="t-sys">// OpenEnv Cluster SRE Command Center</div>
|
| 613 |
+
<div class="t-sys">// Select an agent β pick a task β click Reset β Agent Step</div>
|
| 614 |
+
</div>
|
| 615 |
+
</div>
|
| 616 |
+
</div>
|
| 617 |
+
|
| 618 |
+
<div style="display:flex;flex-direction:column;gap:10px;">
|
| 619 |
+
<!-- API EXPLORER -->
|
| 620 |
+
<div class="card">
|
| 621 |
+
<div class="card-header">
|
| 622 |
+
<span class="card-title">π API Explorer</span>
|
| 623 |
+
<span style="font-family:var(--mono);font-size:8px;color:var(--muted);" id="apiBaseUrl"></span>
|
| 624 |
+
</div>
|
| 625 |
+
<div class="card-body">
|
| 626 |
+
<div class="api-pills">
|
| 627 |
+
<div class="api-pill" onclick="apiTest('/health','GET')"><span class="m-get">GET</span>/health</div>
|
| 628 |
+
<div class="api-pill" onclick="apiTestTasks()"><span class="m-get">GET</span>/tasks</div>
|
| 629 |
+
<div class="api-pill" onclick="apiTest('/agents','GET')"><span class="m-get">GET</span>/agents</div>
|
| 630 |
+
<div class="api-pill" onclick="apiTest('/state','GET')"><span class="m-get">GET</span>/state</div>
|
| 631 |
+
<div class="api-pill" onclick="apiTestReset()"><span class="m-post">POST</span>/reset</div>
|
| 632 |
+
<div class="api-pill" onclick="apiTestAgentStep()"><span class="m-post">POST</span>/agent/step</div>
|
| 633 |
+
</div>
|
| 634 |
+
<div class="api-output" id="apiOutput">// Click any endpoint to test</div>
|
| 635 |
+
</div>
|
| 636 |
+
</div>
|
| 637 |
+
<!-- JOBS / ALERTS -->
|
| 638 |
+
<div class="card">
|
| 639 |
+
<div class="card-header"><span class="card-title">βοΈ Active Jobs</span></div>
|
| 640 |
+
<div class="card-body"><div id="jobsList" style="font-family:var(--mono);font-size:9px;color:var(--muted);">No active jobs</div></div>
|
| 641 |
+
</div>
|
| 642 |
+
<div class="card">
|
| 643 |
+
<div class="card-header"><span class="card-title">π¨ Alerts</span></div>
|
| 644 |
+
<div class="card-body"><div id="alertsList" style="font-family:var(--mono);font-size:8px;color:var(--muted);line-height:1.7;">β</div></div>
|
| 645 |
+
</div>
|
| 646 |
+
</div>
|
| 647 |
+
</div>
|
| 648 |
+
|
| 649 |
+
<footer class="footer">
|
| 650 |
+
<span>OpenEnv Β· Cluster SRE Β· Distributed Triage</span>
|
| 651 |
+
<span id="footerInfo" style="color:var(--accent);"></span>
|
| 652 |
+
<span>FastAPI + HTML/CSS/JS Β· Port 7860</span>
|
| 653 |
+
</footer>
|
| 654 |
+
</div>
|
| 655 |
+
|
| 656 |
+
<script>
|
| 657 |
+
'use strict';
|
| 658 |
+
|
| 659 |
+
/* ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 660 |
+
STATE
|
| 661 |
+
ββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 662 |
+
var BASE = window.location.origin;
|
| 663 |
+
var selectedAgentId = null;
|
| 664 |
+
var selectedTaskId = null;
|
| 665 |
+
var selectedTaskMeta = null; /* {id, name, max_steps, ...} */
|
| 666 |
+
var agentsData = []; /* from GET /agents */
|
| 667 |
+
var rewardHistory = [];
|
| 668 |
+
var totalReward = 0;
|
| 669 |
+
var stepCount = 0;
|
| 670 |
+
var maxSteps = 25;
|
| 671 |
+
var episodeDone = false;
|
| 672 |
+
var isRunning = false;
|
| 673 |
+
|
| 674 |
+
/* Task briefings β kept client-side for instant display */
|
| 675 |
+
var TASK_DESCS = {
|
| 676 |
+
easy:
|
| 677 |
+
'π’ EASY β The Stuck Job\n\n' +
|
| 678 |
+
'A single rogue MapReduce job is stuck in an infinite loop,\nconsuming all RAM on worker_01.\n\n' +
|
| 679 |
+
'Goal: Kill the rogue job (job_rogue_99).\nExpected Steps: 1',
|
| 680 |
+
medium:
|
| 681 |
+
'π‘ MEDIUM β The Full Disk\n\n' +
|
| 682 |
+
'Worker node worker_03 has hit 99.9% disk capacity.\nAll writes are failing.\n\n' +
|
| 683 |
+
'Goal: Clear the storage, then restart the node safely.\nExpected Steps: 2',
|
| 684 |
+
hard:
|
| 685 |
+
'π HARD β The Cascade Failure\n\n' +
|
| 686 |
+
'job_rogue_99 caused a HDFS replication storm,\ncrashing Nodes 1 & 2. Failover traffic is\noverloading Nodes 3 & 4.\n\n' +
|
| 687 |
+
'Goal: Kill root job β clear worker_01 & worker_02 β restart both.\nExpected Steps: 5',
|
| 688 |
+
very_hard:
|
| 689 |
+
'π΄ VERY HARD β Multi-Vector Attack\n\n' +
|
| 690 |
+
'Two simultaneous malware jobs:\n β’ disk-filling log spammer (worker_01/02)\n β’ RAM-hogging crypto miner (worker_03/04)\n\n' +
|
| 691 |
+
'Goal: Kill BOTH jobs β clear dead nodes β reboot them.\nExpected Steps: 6',
|
| 692 |
+
nightmare:
|
| 693 |
+
'β οΈ NIGHTMARE β The Hydra Protocol\n\n' +
|
| 694 |
+
'Total cluster collapse. All 4 nodes offline.\nTHREE self-replicating malware jobs are writing\ngarbage data to every disk simultaneously.\n\n' +
|
| 695 |
+
'β οΈ If ANY Hydra survives, it will instantly refill cleared storage!\n\n' +
|
| 696 |
+
'Goal: Kill ALL 3 Hydras β clear all 4 nodes β restart all 4.\nExpected Steps: 11',
|
| 697 |
+
partition_basic:
|
| 698 |
+
'π’ BASIC β The Partition\n\n' +
|
| 699 |
+
'DC1βDC2 primary link severed. Backup subnets congested.\n' +
|
| 700 |
+
'3 agents: Orchestrator β NetOps β Orchestrator\n\n' +
|
| 701 |
+
'Goal: Establish bypass routing and verify connectivity.\nExpected Steps: ~5',
|
| 702 |
+
replication_storm:
|
| 703 |
+
'π‘ STORM β Replication Storm\n\n' +
|
| 704 |
+
'Network partition + HDFS replication storm consuming 100% I/O.\n' +
|
| 705 |
+
'3 agents must coordinate: fix network FIRST, then kill storm.\n\n' +
|
| 706 |
+
'Goal: Restore network β halt replication storm.\nExpected Steps: ~10',
|
| 707 |
+
split_brain:
|
| 708 |
+
'π΄ SPLIT β The Split-Brain Collapse\n\n' +
|
| 709 |
+
'CRITICAL MULTI-SYSTEM FAILURE:\n β’ Network partition (primary link severed)\n β’ HDFS replication storm (100% I/O)\n β’ NewSQL split-brain (dual leaders, 15 conflicts)\n\n' +
|
| 710 |
+
'β οΈ Reconciling before routing verification causes\nDELAYED CORRUPTION 10 steps later!\n\n' +
|
| 711 |
+
'Goal: Fix network β kill storm β stepdown β reconcile.\nExpected Steps: ~12',
|
| 712 |
+
};
|
| 713 |
+
|
| 714 |
+
/* ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 715 |
+
API FETCHERL
|
| 716 |
+
ββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 717 |
+
function apiFetch(path, method, body) {
|
| 718 |
+
var opts = { method: method, headers: { 'Content-Type': 'application/json' }, cache: 'no-store' };
|
| 719 |
+
if (body !== null && body !== undefined) opts.body = JSON.stringify(body);
|
| 720 |
+
return fetch(BASE + path, opts)
|
| 721 |
+
.then(function (r) {
|
| 722 |
+
var ct = r.headers.get('content-type') || '';
|
| 723 |
+
if (ct.indexOf('json') < 0) return r.text().then(function (t) { return { ok: false, data: null, err: 'HTTP ' + r.status }; });
|
| 724 |
+
return r.json().then(function (d) {
|
| 725 |
+
if (!r.ok) return { ok: false, data: null, err: (d && d.detail) ? String(d.detail) : 'HTTP ' + r.status };
|
| 726 |
+
return { ok: true, data: d, err: null };
|
| 727 |
+
});
|
| 728 |
+
})
|
| 729 |
+
.catch(function (e) { return { ok: false, data: null, err: e.message }; });
|
| 730 |
+
}
|
| 731 |
+
|
| 732 |
+
/* ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 733 |
+
TERMINAL
|
| 734 |
+
ββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 735 |
+
function tlog(cls, txt) {
|
| 736 |
+
var el = document.getElementById('terminal');
|
| 737 |
+
var d = document.createElement('div');
|
| 738 |
+
d.className = cls;
|
| 739 |
+
d.textContent = txt;
|
| 740 |
+
el.appendChild(d);
|
| 741 |
+
el.scrollTop = el.scrollHeight;
|
| 742 |
+
}
|
| 743 |
+
function clearTerminal() {
|
| 744 |
+
document.getElementById('terminal').innerHTML = '<div class="t-sys">// Terminal cleared</div>';
|
| 745 |
+
}
|
| 746 |
+
|
| 747 |
+
/* ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 748 |
+
SIDEBAR β AGENT β TASK HIERARCHY
|
| 749 |
+
ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 750 |
+
Fetches GET /agents and builds the collapsible
|
| 751 |
+
sidebar. Each agent expands to show its tasks.
|
| 752 |
+
EXTENSIBILITY: new agents in the registry
|
| 753 |
+
auto-appear here with zero HTML changes.
|
| 754 |
+
ββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 755 |
+
function loadAgents() {
|
| 756 |
+
apiFetch('/agents', 'GET', null).then(function (res) {
|
| 757 |
+
var container = document.getElementById('sidebarAgents');
|
| 758 |
+
if (!res.ok) {
|
| 759 |
+
container.innerHTML = '<div style="font-family:var(--mono);font-size:10px;color:var(--red);">Failed: ' + res.err + '</div>';
|
| 760 |
+
return;
|
| 761 |
+
}
|
| 762 |
+
agentsData = res.data;
|
| 763 |
+
container.innerHTML = '';
|
| 764 |
+
|
| 765 |
+
agentsData.forEach(function (agent, idx) {
|
| 766 |
+
var group = document.createElement('div');
|
| 767 |
+
group.className = 'agent-group';
|
| 768 |
+
group.id = 'ag_' + agent.id;
|
| 769 |
+
|
| 770 |
+
/* Agent header (clickable, expands task list) */
|
| 771 |
+
var header = document.createElement('div');
|
| 772 |
+
header.className = 'agent-header';
|
| 773 |
+
header.id = 'ah_' + agent.id;
|
| 774 |
+
header.innerHTML =
|
| 775 |
+
'<span class="agent-icon">' + agent.icon + '</span>' +
|
| 776 |
+
'<div class="agent-header-text">' +
|
| 777 |
+
'<div class="agent-name">' + agent.name + '</div>' +
|
| 778 |
+
'<div class="agent-desc-mini">' + agent.description.slice(0, 50) + '</div>' +
|
| 779 |
+
'</div>' +
|
| 780 |
+
'<span class="agent-chevron">βΈ</span>';
|
| 781 |
+
header.onclick = function () { toggleAgent(agent.id); };
|
| 782 |
+
|
| 783 |
+
/* Task list (hidden by default) */
|
| 784 |
+
var taskList = document.createElement('div');
|
| 785 |
+
taskList.className = 'task-list';
|
| 786 |
+
taskList.id = 'tl_' + agent.id;
|
| 787 |
+
|
| 788 |
+
var inner = document.createElement('div');
|
| 789 |
+
inner.className = 'task-list-inner';
|
| 790 |
+
|
| 791 |
+
agent.tasks.forEach(function (task) {
|
| 792 |
+
var item = document.createElement('div');
|
| 793 |
+
item.className = 'task-item';
|
| 794 |
+
item.id = 'ti_' + agent.id + '_' + task.id;
|
| 795 |
+
item.innerHTML =
|
| 796 |
+
'<span class="task-dot" style="background:' + task.color + ';"></span>' +
|
| 797 |
+
'<span class="task-item-name">' + task.name + '</span>' +
|
| 798 |
+
'<span class="task-item-diff" style="color:' + (task.label_color || task.color) + ';background:' + (task.label_color || task.color) + '18;">' + task.label + '</span>';
|
| 799 |
+
item.onclick = function (e) {
|
| 800 |
+
e.stopPropagation();
|
| 801 |
+
selectTask(agent.id, task);
|
| 802 |
+
};
|
| 803 |
+
inner.appendChild(item);
|
| 804 |
+
});
|
| 805 |
+
|
| 806 |
+
taskList.appendChild(inner);
|
| 807 |
+
group.appendChild(header);
|
| 808 |
+
group.appendChild(taskList);
|
| 809 |
+
container.appendChild(group);
|
| 810 |
+
|
| 811 |
+
});
|
| 812 |
+
|
| 813 |
+
/* Auto-expand the first agent after all agents are added to the DOM */
|
| 814 |
+
if (agentsData.length > 0) {
|
| 815 |
+
toggleAgent(agentsData[0].id);
|
| 816 |
+
}
|
| 817 |
+
|
| 818 |
+
/* "Add Agent" placeholder */
|
| 819 |
+
var addSlot = document.createElement('div');
|
| 820 |
+
addSlot.className = 'add-agent-slot';
|
| 821 |
+
addSlot.innerHTML = '<span class="add-agent-icon">οΌ</span><div class="add-agent-text">Add Agent<br>agents/__init__.py</div>';
|
| 822 |
+
container.appendChild(addSlot);
|
| 823 |
+
|
| 824 |
+
document.getElementById('agentCountTag').textContent = agentsData.length + ' agent' + (agentsData.length !== 1 ? 's' : '');
|
| 825 |
+
});
|
| 826 |
+
}
|
| 827 |
+
|
| 828 |
+
function toggleAgent(agentId) {
|
| 829 |
+
/* Collapse all other agents, expand this one */
|
| 830 |
+
agentsData.forEach(function (a) {
|
| 831 |
+
var h = document.getElementById('ah_' + a.id);
|
| 832 |
+
var tl = document.getElementById('tl_' + a.id);
|
| 833 |
+
if (a.id === agentId) {
|
| 834 |
+
var isOpen = tl.classList.contains('open');
|
| 835 |
+
if (!isOpen) {
|
| 836 |
+
h.classList.add('active');
|
| 837 |
+
tl.classList.add('open');
|
| 838 |
+
selectedAgentId = agentId;
|
| 839 |
+
} else {
|
| 840 |
+
h.classList.remove('active');
|
| 841 |
+
tl.classList.remove('open');
|
| 842 |
+
}
|
| 843 |
+
} else {
|
| 844 |
+
h.classList.remove('active');
|
| 845 |
+
tl.classList.remove('open');
|
| 846 |
+
}
|
| 847 |
+
});
|
| 848 |
+
}
|
| 849 |
+
|
| 850 |
+
function selectTask(agentId, task) {
|
| 851 |
+
/* Make sure this agent is expanded */
|
| 852 |
+
selectedAgentId = agentId;
|
| 853 |
+
selectedTaskId = task.id;
|
| 854 |
+
selectedTaskMeta = task;
|
| 855 |
+
maxSteps = task.max_steps;
|
| 856 |
+
|
| 857 |
+
/* Highlight in sidebar */
|
| 858 |
+
document.querySelectorAll('.task-item').forEach(function (ti) { ti.classList.remove('selected'); });
|
| 859 |
+
var el = document.getElementById('ti_' + agentId + '_' + task.id);
|
| 860 |
+
if (el) el.classList.add('selected');
|
| 861 |
+
|
| 862 |
+
/* Expand parent agent header */
|
| 863 |
+
var h = document.getElementById('ah_' + agentId);
|
| 864 |
+
var tl = document.getElementById('tl_' + agentId);
|
| 865 |
+
if (h) h.classList.add('active');
|
| 866 |
+
if (tl) tl.classList.add('open');
|
| 867 |
+
|
| 868 |
+
/* Update briefing */
|
| 869 |
+
document.getElementById('briefingText').textContent = TASK_DESCS[task.id] || task.name;
|
| 870 |
+
document.getElementById('stepCounterScore').textContent = '0 / ' + task.max_steps + ' steps';
|
| 871 |
+
|
| 872 |
+
/* Find agent name for footer */
|
| 873 |
+
var agent = agentsData.find(function (a) { return a.id === agentId; });
|
| 874 |
+
document.getElementById('footerInfo').textContent = (agent ? agent.name : agentId) + ' Β· ' + task.name;
|
| 875 |
+
|
| 876 |
+
tlog('t-sys', '// Selected: ' + (agent ? agent.name : agentId) + ' β ' + task.name + ' [' + task.label + ']');
|
| 877 |
+
}
|
| 878 |
+
|
| 879 |
+
/* ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 880 |
+
NODE GRID
|
| 881 |
+
ββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 882 |
+
function renderNodes(nodes) {
|
| 883 |
+
var grid = document.getElementById('nodeGrid');
|
| 884 |
+
grid.innerHTML = '';
|
| 885 |
+
var healthy = 0;
|
| 886 |
+
nodes.forEach(function (n) {
|
| 887 |
+
if (n.status === 'healthy') healthy++;
|
| 888 |
+
var dc = n.disk_usage > 85 ? '#ef4444' : n.disk_usage > 60 ? '#eab308' : '#10b981';
|
| 889 |
+
var rc = n.ram_usage > 85 ? '#ef4444' : n.ram_usage > 60 ? '#eab308' : '#10b981';
|
| 890 |
+
var c = document.createElement('div');
|
| 891 |
+
c.className = 'node-card ' + n.status + ' fade-in';
|
| 892 |
+
c.innerHTML =
|
| 893 |
+
'<div class="node-id">' + n.node_id + '</div>' +
|
| 894 |
+
'<div class="node-status ' + n.status + '">' + n.status.replace('_', ' ') + '</div>' +
|
| 895 |
+
'<div class="node-metrics">' +
|
| 896 |
+
'<span>Disk <b style="color:' + dc + '">' + n.disk_usage.toFixed(1) + '%</b></span>' +
|
| 897 |
+
'<span>RAM <b style="color:' + rc + '">' + n.ram_usage.toFixed(1) + '%</b></span>' +
|
| 898 |
+
'</div>' +
|
| 899 |
+
'<div class="metric-bar"><div class="metric-bar-fill" style="width:' + Math.min(n.disk_usage, 100) + '%;background:' + dc + '"></div></div>' +
|
| 900 |
+
'<div class="metric-bar" style="margin-top:2px"><div class="metric-bar-fill" style="width:' + Math.min(n.ram_usage, 100) + '%;background:' + rc + '"></div></div>';
|
| 901 |
+
grid.appendChild(c);
|
| 902 |
+
});
|
| 903 |
+
document.getElementById('nodeSub').textContent = nodes.length + ' nodes Β· ' + healthy + ' healthy Β· ' + (nodes.length - healthy) + ' degraded';
|
| 904 |
+
}
|
| 905 |
+
|
| 906 |
+
/* ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 907 |
+
SCORE
|
| 908 |
+
ββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 909 |
+
function updateScore(reward, health) {
|
| 910 |
+
totalReward += reward;
|
| 911 |
+
var el = document.getElementById('scoreReward');
|
| 912 |
+
el.textContent = (reward >= 0 ? '+' : '') + reward.toFixed(3);
|
| 913 |
+
el.style.color = reward > 0 ? '#10b981' : reward < 0 ? '#ef4444' : '#71717a';
|
| 914 |
+
document.getElementById('scoreHealth').textContent = health.toFixed(2);
|
| 915 |
+
document.getElementById('healthTag').textContent = 'Health: ' + (health * 100).toFixed(0) + '%';
|
| 916 |
+
document.getElementById('scoreTotal').textContent = totalReward.toFixed(3);
|
| 917 |
+
document.getElementById('stepCounter').textContent = 'Step ' + stepCount;
|
| 918 |
+
document.getElementById('stepCounterScore').textContent = stepCount + ' / ' + maxSteps + ' steps';
|
| 919 |
+
rewardHistory.push(reward);
|
| 920 |
+
renderRewardBars();
|
| 921 |
+
}
|
| 922 |
+
function renderRewardBars() {
|
| 923 |
+
var c = document.getElementById('rewardBars');
|
| 924 |
+
var d = rewardHistory.slice(-12);
|
| 925 |
+
if (!d.length) return;
|
| 926 |
+
c.innerHTML = '';
|
| 927 |
+
var s = 0;
|
| 928 |
+
d.forEach(function (r) {
|
| 929 |
+
var b = document.createElement('div');
|
| 930 |
+
b.className = 'rbar ' + (r >= 0 ? 'pos' : 'neg');
|
| 931 |
+
b.style.height = Math.max(4, Math.round(Math.abs(r) * 100)) + '%';
|
| 932 |
+
c.appendChild(b); s += r;
|
| 933 |
+
});
|
| 934 |
+
document.getElementById('avgRwd').textContent = 'avg ' + (s / d.length).toFixed(3);
|
| 935 |
+
}
|
| 936 |
+
|
| 937 |
+
/* ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 938 |
+
JOBS + ALERTS
|
| 939 |
+
ββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 940 |
+
function renderObs(obs) {
|
| 941 |
+
var j = document.getElementById('jobsList');
|
| 942 |
+
if (!obs.active_jobs || !obs.active_jobs.length) {
|
| 943 |
+
j.innerHTML = '<span style="color:var(--green);">No active jobs β</span>';
|
| 944 |
+
} else {
|
| 945 |
+
j.innerHTML = obs.active_jobs.map(function (x) {
|
| 946 |
+
var cl = x.status === 'hanging' ? 'var(--red)' : 'var(--yellow)';
|
| 947 |
+
return '<div style="color:' + cl + ';margin-bottom:2px;">β ' + x.job_id + ' <span style="color:var(--muted);">[' + x.status + ' Β· ' + x.ram_allocated + 'GB]</span></div>';
|
| 948 |
+
}).join('');
|
| 949 |
+
}
|
| 950 |
+
var a = document.getElementById('alertsList');
|
| 951 |
+
if (!obs.recent_alerts || !obs.recent_alerts.length) {
|
| 952 |
+
a.textContent = 'None';
|
| 953 |
+
} else {
|
| 954 |
+
a.innerHTML = obs.recent_alerts.map(function (x) {
|
| 955 |
+
var cl = /^(FATAL|CRITICAL)/.test(x) ? 'var(--red)' : /^(WARN)/.test(x) ? 'var(--yellow)' : /^(GOOD|SUCCESS)/.test(x) ? 'var(--green)' : 'var(--subtle)';
|
| 956 |
+
return '<div style="color:' + cl + ';margin-bottom:1px;">' + x + '</div>';
|
| 957 |
+
}).join('');
|
| 958 |
+
}
|
| 959 |
+
}
|
| 960 |
+
|
| 961 |
+
/* ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 962 |
+
RESET
|
| 963 |
+
ββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 964 |
+
function doReset() {
|
| 965 |
+
if (!selectedAgentId || !selectedTaskId) {
|
| 966 |
+
tlog('t-warn', '[WARN] Select an agent and a task first.');
|
| 967 |
+
return;
|
| 968 |
+
}
|
| 969 |
+
rewardHistory = []; totalReward = 0; stepCount = 0; episodeDone = false;
|
| 970 |
+
document.getElementById('scoreReward').textContent = '+0.000';
|
| 971 |
+
document.getElementById('scoreReward').style.color = 'var(--muted)';
|
| 972 |
+
document.getElementById('scoreHealth').textContent = 'β';
|
| 973 |
+
document.getElementById('scoreTotal').textContent = '0.000';
|
| 974 |
+
document.getElementById('stepCounter').textContent = 'Step 0';
|
| 975 |
+
document.getElementById('stepCounterScore').textContent = '0 / ' + maxSteps + ' steps';
|
| 976 |
+
document.getElementById('avgRwd').textContent = 'avg β';
|
| 977 |
+
document.getElementById('rewardBars').innerHTML = '<div style="font-family:var(--mono);font-size:8px;color:var(--muted);margin:auto;">No steps yet</div>';
|
| 978 |
+
document.getElementById('doneTag').style.display = 'none';
|
| 979 |
+
document.getElementById('healthTag').textContent = 'Health: β';
|
| 980 |
+
document.getElementById('btnStep').disabled = false;
|
| 981 |
+
|
| 982 |
+
tlog('t-reset', 'β'.repeat(50));
|
| 983 |
+
tlog('t-reset', ' π RESET β ' + selectedTaskId.toUpperCase().replace('_', ' '));
|
| 984 |
+
tlog('t-reset', 'β'.repeat(50));
|
| 985 |
+
|
| 986 |
+
apiFetch('/reset', 'POST', { agent_id: selectedAgentId, task: selectedTaskId }).then(function (res) {
|
| 987 |
+
if (!res.ok) { tlog('t-err', '[ERROR] ' + res.err); return; }
|
| 988 |
+
var obs = res.data;
|
| 989 |
+
renderNodes(obs.nodes || []);
|
| 990 |
+
renderObs(obs);
|
| 991 |
+
document.getElementById('scoreHealth').textContent = obs.health_score.toFixed(2);
|
| 992 |
+
document.getElementById('healthTag').textContent = 'Health: ' + (obs.health_score * 100).toFixed(0) + '%';
|
| 993 |
+
tlog('t-ok', '[READY] Health: ' + obs.health_score.toFixed(2) + ' | Jobs: ' +
|
| 994 |
+
(obs.active_jobs.map(function (j) { return j.job_id; }).join(', ') || 'none'));
|
| 995 |
+
tlog('t-sys', '// Click βΆ Agent Step to run the LLM.');
|
| 996 |
+
});
|
| 997 |
+
}
|
| 998 |
+
|
| 999 |
+
/* ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1000 |
+
STATE
|
| 1001 |
+
ββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 1002 |
+
function doState() {
|
| 1003 |
+
apiFetch('/state', 'GET', null).then(function (res) {
|
| 1004 |
+
if (!res.ok) { tlog('t-err', '[ERROR] ' + res.err); return; }
|
| 1005 |
+
var obs = res.data;
|
| 1006 |
+
tlog('t-info', '--- π STATE (step ' + stepCount + ') ---');
|
| 1007 |
+
tlog('t-info', 'Health: ' + obs.health_score.toFixed(2) + ' | ' +
|
| 1008 |
+
obs.nodes.map(function (n) { return n.node_id + '(' + n.status + ')'; }).join(', '));
|
| 1009 |
+
renderNodes(obs.nodes);
|
| 1010 |
+
renderObs(obs);
|
| 1011 |
+
});
|
| 1012 |
+
}
|
| 1013 |
+
|
| 1014 |
+
/* ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1015 |
+
AGENT STEP
|
| 1016 |
+
ββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 1017 |
+
function doAgentStep() {
|
| 1018 |
+
if (isRunning || episodeDone) return;
|
| 1019 |
+
if (!selectedAgentId || !selectedTaskId) {
|
| 1020 |
+
tlog('t-warn', '[WARN] Select agent + task and Reset first.');
|
| 1021 |
+
return;
|
| 1022 |
+
}
|
| 1023 |
+
isRunning = true;
|
| 1024 |
+
setStepUI(true);
|
| 1025 |
+
|
| 1026 |
+
apiFetch('/agent/step', 'POST', { agent_id: selectedAgentId, task: selectedTaskId }).then(function (res) {
|
| 1027 |
+
isRunning = false;
|
| 1028 |
+
setStepUI(false);
|
| 1029 |
+
if (!res.ok) { tlog('t-err', '[API ERROR] ' + res.err); return; }
|
| 1030 |
+
|
| 1031 |
+
var d = res.data;
|
| 1032 |
+
stepCount = d.step;
|
| 1033 |
+
var reward = d.reward;
|
| 1034 |
+
var done = d.done;
|
| 1035 |
+
var msg = d.message || '';
|
| 1036 |
+
var action = d.action || {};
|
| 1037 |
+
var obs = d.observation;
|
| 1038 |
+
|
| 1039 |
+
var actStr = (action.action_type || '?') + ' β ' + (action.target_id || '?');
|
| 1040 |
+
var rCls = reward > 0 ? 't-rpos' : reward < 0 ? 't-rneg' : 't-sys';
|
| 1041 |
+
tlog('t-step', 'Step ' + String(stepCount).padStart(2, ' ') + ' | ' + actStr);
|
| 1042 |
+
tlog(rCls, ' | reward=' + (reward >= 0 ? '+' : '') + reward.toFixed(3) +
|
| 1043 |
+
' | health=' + obs.health_score.toFixed(2) + ' | ' + msg);
|
| 1044 |
+
|
| 1045 |
+
updateScore(reward, obs.health_score);
|
| 1046 |
+
renderNodes(obs.nodes);
|
| 1047 |
+
renderObs(obs);
|
| 1048 |
+
|
| 1049 |
+
if (done) {
|
| 1050 |
+
episodeDone = true;
|
| 1051 |
+
var win = obs.health_score >= 1.0;
|
| 1052 |
+
tlog('t-reset', 'β'.repeat(50));
|
| 1053 |
+
tlog(win ? 't-ok' : 't-err',
|
| 1054 |
+
' ' + (win ? 'β
SYSTEM NOMINAL β INCIDENT RESOLVED' : 'β EPISODE ENDED β CLUSTER FAILED'));
|
| 1055 |
+
tlog('t-reset', 'β'.repeat(50));
|
| 1056 |
+
document.getElementById('doneTag').style.display = 'inline';
|
| 1057 |
+
document.getElementById('btnStep').disabled = true;
|
| 1058 |
+
}
|
| 1059 |
+
});
|
| 1060 |
+
}
|
| 1061 |
+
|
| 1062 |
+
function setStepUI(on) {
|
| 1063 |
+
document.getElementById('stepSpinner').className = 'spinner' + (on ? ' on' : '');
|
| 1064 |
+
document.getElementById('termSpinner').className = 'spinner' + (on ? ' on' : '');
|
| 1065 |
+
document.getElementById('llmTag').style.display = on ? 'inline' : 'none';
|
| 1066 |
+
document.getElementById('stepBtnTxt').textContent = on ? 'Thinking...' : 'βΆ Agent Step';
|
| 1067 |
+
document.getElementById('btnStep').disabled = on;
|
| 1068 |
+
}
|
| 1069 |
+
|
| 1070 |
+
/* ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1071 |
+
API EXPLORER
|
| 1072 |
+
ββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 1073 |
+
function apiTest(path, method, body) {
|
| 1074 |
+
var out = document.getElementById('apiOutput');
|
| 1075 |
+
out.textContent = '// ' + method + ' ' + path + ' ...';
|
| 1076 |
+
apiFetch(path, method, body || null).then(function (res) {
|
| 1077 |
+
out.textContent = res.ok ? JSON.stringify(res.data, null, 2).slice(0, 800) : '// ERROR: ' + res.err;
|
| 1078 |
+
});
|
| 1079 |
+
}
|
| 1080 |
+
function apiTestTasks() {
|
| 1081 |
+
apiTest('/tasks?agent_id=' + (selectedAgentId || 'split_brain'), 'GET');
|
| 1082 |
+
}
|
| 1083 |
+
function apiTestReset() {
|
| 1084 |
+
apiTest('/reset', 'POST', { agent_id: selectedAgentId || 'split_brain', task: selectedTaskId || 'partition_basic' });
|
| 1085 |
+
}
|
| 1086 |
+
function apiTestAgentStep() {
|
| 1087 |
+
apiTest('/agent/step', 'POST', { agent_id: selectedAgentId || 'split_brain', task: selectedTaskId || 'partition_basic' });
|
| 1088 |
+
}
|
| 1089 |
+
|
| 1090 |
+
/* ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1091 |
+
INIT
|
| 1092 |
+
ββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 1093 |
+
function init() {
|
| 1094 |
+
document.getElementById('apiBaseUrl').textContent = BASE;
|
| 1095 |
+
apiFetch('/health', 'GET', null).then(function (res) {
|
| 1096 |
+
var dot = document.getElementById('statusDot');
|
| 1097 |
+
var txt = document.getElementById('statusText');
|
| 1098 |
+
if (res.ok) {
|
| 1099 |
+
dot.className = 'status-dot live';
|
| 1100 |
+
txt.textContent = 'Online Β· ' + (res.data.env || 'cluster-triage');
|
| 1101 |
+
document.getElementById('versionTag').textContent = 'v' + (res.data.version || '1.0.0');
|
| 1102 |
+
} else {
|
| 1103 |
+
dot.className = 'status-dot err';
|
| 1104 |
+
txt.textContent = 'Offline';
|
| 1105 |
+
}
|
| 1106 |
+
});
|
| 1107 |
+
loadAgents();
|
| 1108 |
+
}
|
| 1109 |
+
|
| 1110 |
+
init();
|
| 1111 |
+
|
| 1112 |
+
/* ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1113 |
+
SPLIT-BRAIN WAR ROOM
|
| 1114 |
+
ββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 1115 |
+
var cyInstance = null;
|
| 1116 |
+
var sbTotalReward = 0;
|
| 1117 |
+
|
| 1118 |
+
var ACTOR_INFO = {
|
| 1119 |
+
orchestrator: { icon: 'π―', name: 'Orchestrator', cls: 'wr-orch' },
|
| 1120 |
+
netops: { icon: 'π', name: 'NetOps', cls: 'wr-netops' },
|
| 1121 |
+
dataops: { icon: 'πΎ', name: 'DataOps', cls: 'wr-dataops' },
|
| 1122 |
+
system: { icon: 'β‘', name: 'System', cls: 'wr-system' },
|
| 1123 |
+
};
|
| 1124 |
+
|
| 1125 |
+
function isSplitBrain() { return selectedAgentId === 'split_brain'; }
|
| 1126 |
+
|
| 1127 |
+
function toggleWarRoom(show) {
|
| 1128 |
+
var wr = document.getElementById('warroom');
|
| 1129 |
+
var mg = document.querySelector('.main-grid');
|
| 1130 |
+
var center = document.getElementById('centerPanel');
|
| 1131 |
+
var score = document.getElementById('scoreCard');
|
| 1132 |
+
if (show) {
|
| 1133 |
+
wr.classList.add('active');
|
| 1134 |
+
mg.style.gridTemplateColumns = '220px 1fr';
|
| 1135 |
+
if (center) center.style.display = 'none';
|
| 1136 |
+
if (score) score.style.display = 'none';
|
| 1137 |
+
} else {
|
| 1138 |
+
wr.classList.remove('active');
|
| 1139 |
+
mg.style.gridTemplateColumns = '';
|
| 1140 |
+
if (center) center.style.display = '';
|
| 1141 |
+
if (score) score.style.display = '';
|
| 1142 |
+
}
|
| 1143 |
+
}
|
| 1144 |
+
|
| 1145 |
+
function wrAddMsg(actor, text) {
|
| 1146 |
+
var info = ACTOR_INFO[actor] || ACTOR_INFO.system;
|
| 1147 |
+
var chat = document.getElementById('wrChat');
|
| 1148 |
+
var div = document.createElement('div');
|
| 1149 |
+
div.className = 'wr-msg ' + info.cls;
|
| 1150 |
+
div.innerHTML = '<div class="wr-msg-avatar">' + info.icon + '</div>' +
|
| 1151 |
+
'<div class="wr-msg-body"><div class="wr-msg-name">' + info.name + '</div>' +
|
| 1152 |
+
'<div class="wr-msg-text">' + text.replace(/</g,'<').replace(/>/g,'>') + '</div></div>';
|
| 1153 |
+
chat.appendChild(div);
|
| 1154 |
+
chat.scrollTop = chat.scrollHeight;
|
| 1155 |
+
}
|
| 1156 |
+
|
| 1157 |
+
function updateActorBadge(actor) {
|
| 1158 |
+
var badge = document.getElementById('actorBadge');
|
| 1159 |
+
var info = ACTOR_INFO[actor] || ACTOR_INFO.orchestrator;
|
| 1160 |
+
badge.className = 'actor-badge ' + actor;
|
| 1161 |
+
badge.textContent = info.icon + ' ' + info.name;
|
| 1162 |
+
}
|
| 1163 |
+
|
| 1164 |
+
/* ββ Cytoscape.js Topology ββββββββββββββββββββββββ */
|
| 1165 |
+
function initCytoscape(nodes, edges) {
|
| 1166 |
+
var elements = [];
|
| 1167 |
+
nodes.forEach(function(n) {
|
| 1168 |
+
var x = 150; // default for dc1
|
| 1169 |
+
if (n.datacenter === 'dc2') x = 450;
|
| 1170 |
+
if (n.datacenter === 'dc3') x = 750;
|
| 1171 |
+
var yMap = { router: 50, switch: 180, server_a: 310, server_b: 310 };
|
| 1172 |
+
var subtype = n.node_id.replace(n.datacenter + '_', '');
|
| 1173 |
+
var y = yMap[subtype] || 200;
|
| 1174 |
+
if (subtype === 'server_a') x -= 60;
|
| 1175 |
+
if (subtype === 'server_b') x += 60;
|
| 1176 |
+
elements.push({ data: { id: n.node_id, label: n.node_id.replace('_', '\n'), status: n.status, dc: n.datacenter }, position: { x: x, y: y } });
|
| 1177 |
+
});
|
| 1178 |
+
edges.forEach(function(e) {
|
| 1179 |
+
elements.push({ data: { id: e.edge_id, source: e.source, target: e.target, status: e.status } });
|
| 1180 |
+
});
|
| 1181 |
+
if (cyInstance) cyInstance.destroy();
|
| 1182 |
+
cyInstance = cytoscape({
|
| 1183 |
+
container: document.getElementById('cyGraph'),
|
| 1184 |
+
elements: elements,
|
| 1185 |
+
style: [
|
| 1186 |
+
{ selector: 'node', style: { 'label': 'data(label)', 'text-valign': 'center', 'text-halign': 'center', 'font-size': '8px', 'font-family': 'IBM Plex Mono', 'color': '#e4e4e7', 'text-wrap': 'wrap', 'width': 50, 'height': 50, 'border-width': 2, 'background-color': '#131316', 'border-color': '#10b981' } },
|
| 1187 |
+
{ selector: 'node[status="offline"]', style: { 'border-color': '#ef4444', 'background-color': '#1a0505' } },
|
| 1188 |
+
{ selector: 'node[status="degraded"]', style: { 'border-color': '#eab308' } },
|
| 1189 |
+
{ selector: 'node[status="partitioned"]', style: { 'border-color': '#ef4444', 'border-style': 'dashed' } },
|
| 1190 |
+
{ selector: 'edge', style: { 'width': 3, 'line-color': '#10b981', 'curve-style': 'bezier', 'opacity': 0.8 } },
|
| 1191 |
+
{ selector: 'edge[status="severed"]', style: { 'line-color': '#ef4444', 'line-style': 'dashed', 'opacity': 0.4, 'width': 2 } },
|
| 1192 |
+
{ selector: 'edge[status="congested"]', style: { 'line-color': '#eab308', 'width': 4, 'opacity': 0.7 } },
|
| 1193 |
+
{ selector: 'edge[status="bypass"]', style: { 'line-color': '#22d3ee', 'width': 3, 'line-style': 'dotted' } },
|
| 1194 |
+
{ selector: 'edge[status="offline"]', style: { 'line-color': '#3f3f46', 'opacity': 0.3 } },
|
| 1195 |
+
],
|
| 1196 |
+
layout: { name: 'preset' },
|
| 1197 |
+
userZoomingEnabled: false, userPanningEnabled: false, boxSelectionEnabled: false,
|
| 1198 |
+
});
|
| 1199 |
+
}
|
| 1200 |
+
|
| 1201 |
+
function updateCytoscape(nodes, edges) {
|
| 1202 |
+
if (!cyInstance) return;
|
| 1203 |
+
nodes.forEach(function(n) {
|
| 1204 |
+
var el = cyInstance.getElementById(n.node_id);
|
| 1205 |
+
if (el.length) el.data('status', n.status);
|
| 1206 |
+
});
|
| 1207 |
+
edges.forEach(function(e) {
|
| 1208 |
+
var el = cyInstance.getElementById(e.edge_id);
|
| 1209 |
+
if (el.length) el.data('status', e.status);
|
| 1210 |
+
});
|
| 1211 |
+
}
|
| 1212 |
+
|
| 1213 |
+
/* ββ Telemetry Update ββββββββββββββββββββββββββββββ */
|
| 1214 |
+
function gColor(v, inv) { var p = inv ? (100-v) : v; return p > 70 ? '#10b981' : p > 40 ? '#eab308' : '#ef4444'; }
|
| 1215 |
+
|
| 1216 |
+
function updateTelemetry(obs, reward) {
|
| 1217 |
+
var h = (obs.global_health * 100);
|
| 1218 |
+
document.getElementById('tmHealth').textContent = h.toFixed(0) + '%';
|
| 1219 |
+
document.getElementById('tmHealth').style.color = gColor(h, false);
|
| 1220 |
+
document.getElementById('tmHealthBar').style.width = h + '%';
|
| 1221 |
+
document.getElementById('tmHealthBar').style.background = gColor(h, false);
|
| 1222 |
+
|
| 1223 |
+
var io = obs.hdfs.io_bandwidth_used_pct;
|
| 1224 |
+
document.getElementById('tmIO').textContent = io.toFixed(0) + '%';
|
| 1225 |
+
document.getElementById('tmIO').style.color = gColor(io, true);
|
| 1226 |
+
document.getElementById('tmIOBar').style.width = io + '%';
|
| 1227 |
+
document.getElementById('tmIOBar').style.background = gColor(io, true);
|
| 1228 |
+
document.getElementById('tmStorm').textContent = obs.hdfs.replication_storm_active ? 'β REPLICATION STORM' : 'β Stable';
|
| 1229 |
+
document.getElementById('tmStorm').style.color = obs.hdfs.replication_storm_active ? '#ef4444' : '#10b981';
|
| 1230 |
+
|
| 1231 |
+
var drift = obs.newsql.conflicting_entries;
|
| 1232 |
+
var dPct = Math.min(100, drift * 5);
|
| 1233 |
+
document.getElementById('tmDrift').textContent = drift;
|
| 1234 |
+
document.getElementById('tmDrift').style.color = drift > 0 ? '#ef4444' : '#10b981';
|
| 1235 |
+
document.getElementById('tmDriftBar').style.width = dPct + '%';
|
| 1236 |
+
document.getElementById('tmDriftBar').style.background = drift > 0 ? '#ef4444' : '#10b981';
|
| 1237 |
+
document.getElementById('tmBrain').textContent = obs.newsql.split_brain_active ? 'β SPLIT-BRAIN ACTIVE' : 'β Single leader';
|
| 1238 |
+
document.getElementById('tmBrain').style.color = obs.newsql.split_brain_active ? '#ef4444' : '#10b981';
|
| 1239 |
+
|
| 1240 |
+
var ns = obs.network_status;
|
| 1241 |
+
var nsCol = ns === 'healthy' ? '#10b981' : ns === 'degraded' ? '#eab308' : '#ef4444';
|
| 1242 |
+
document.getElementById('tmNet').textContent = ns.toUpperCase();
|
| 1243 |
+
document.getElementById('tmNet').style.color = nsCol;
|
| 1244 |
+
document.getElementById('tmRouting').textContent = 'Routing: ' + (obs.routing_verified ? 'verified β' : 'unverified');
|
| 1245 |
+
document.getElementById('tmRouting').style.color = obs.routing_verified ? '#10b981' : '#ef4444';
|
| 1246 |
+
|
| 1247 |
+
var redis = obs.redis_cache_usage || 0;
|
| 1248 |
+
var rColor = redis >= 100 ? '#ef4444' : redis > 50 ? '#eab308' : '#10b981';
|
| 1249 |
+
document.getElementById('tmRedis').textContent = redis.toFixed(0) + '%';
|
| 1250 |
+
document.getElementById('tmRedis').style.color = rColor;
|
| 1251 |
+
document.getElementById('tmRedisBar').style.width = redis + '%';
|
| 1252 |
+
document.getElementById('tmRedisBar').style.background = rColor;
|
| 1253 |
+
var authStr = obs.auth_status || 'online';
|
| 1254 |
+
document.getElementById('tmAuth').textContent = 'Auth: ' + authStr;
|
| 1255 |
+
document.getElementById('tmAuth').style.color = authStr === 'offline' ? '#ef4444' : '#10b981';
|
| 1256 |
+
|
| 1257 |
+
sbTotalReward += reward;
|
| 1258 |
+
document.getElementById('tmReward').textContent = (reward >= 0 ? '+' : '') + reward.toFixed(2);
|
| 1259 |
+
document.getElementById('tmReward').style.color = reward > 0 ? '#10b981' : reward < 0 ? '#ef4444' : 'var(--muted)';
|
| 1260 |
+
document.getElementById('tmTotal').textContent = sbTotalReward.toFixed(2);
|
| 1261 |
+
document.getElementById('tmTotal').style.color = sbTotalReward >= 0 ? '#10b981' : '#ef4444';
|
| 1262 |
+
|
| 1263 |
+
document.getElementById('topoStatus').textContent = ns + ' | step ' + obs.step;
|
| 1264 |
+
}
|
| 1265 |
+
|
| 1266 |
+
/* ββ Override doReset for split-brain ββββββββββββ */
|
| 1267 |
+
var _origReset = doReset;
|
| 1268 |
+
doReset = function() {
|
| 1269 |
+
if (!isSplitBrain()) { toggleWarRoom(false); _origReset(); return; }
|
| 1270 |
+
if (!selectedTaskId) { tlog('t-warn','[WARN] Select a task first.'); return; }
|
| 1271 |
+
toggleWarRoom(true);
|
| 1272 |
+
rewardHistory = []; totalReward = 0; stepCount = 0; episodeDone = false; sbTotalReward = 0;
|
| 1273 |
+
document.getElementById('wrChat').innerHTML = '';
|
| 1274 |
+
document.getElementById('btnStep').disabled = false;
|
| 1275 |
+
document.getElementById('doneTag').style.display = 'none';
|
| 1276 |
+
document.getElementById('scoreReward').textContent = '+0.000';
|
| 1277 |
+
document.getElementById('scoreReward').style.color = 'var(--muted)';
|
| 1278 |
+
document.getElementById('scoreHealth').textContent = 'β';
|
| 1279 |
+
document.getElementById('scoreTotal').textContent = '0.000';
|
| 1280 |
+
document.getElementById('stepCounter').textContent = 'Step 0';
|
| 1281 |
+
document.getElementById('stepCounterScore').textContent = '0 / ' + maxSteps + ' steps';
|
| 1282 |
+
document.getElementById('rewardBars').innerHTML = '<div style="font-family:var(--mono);font-size:8px;color:var(--muted);margin:auto;">No steps yet</div>';
|
| 1283 |
+
wrAddMsg('system', 'π INCIDENT RESET β ' + selectedTaskId.toUpperCase().replace('_',' '));
|
| 1284 |
+
tlog('t-reset', 'β'.repeat(50));
|
| 1285 |
+
tlog('t-reset', ' π§ SPLIT-BRAIN β ' + selectedTaskId.toUpperCase().replace('_',' '));
|
| 1286 |
+
tlog('t-reset', 'β'.repeat(50));
|
| 1287 |
+
apiFetch('/reset','POST',{agent_id:'split_brain',task:selectedTaskId}).then(function(res){
|
| 1288 |
+
if (!res.ok) { tlog('t-err','[ERROR] ' + res.err); return; }
|
| 1289 |
+
var obs = res.data;
|
| 1290 |
+
initCytoscape(obs.network_nodes, obs.network_edges);
|
| 1291 |
+
updateTelemetry(obs, 0);
|
| 1292 |
+
updateActorBadge(obs.current_actor);
|
| 1293 |
+
document.getElementById('briefingText').textContent = TASK_DESCS[selectedTaskId] || selectedTaskId;
|
| 1294 |
+
wrAddMsg('system', 'Health: ' + (obs.global_health*100).toFixed(0) + '% | Actor: ' + obs.current_actor);
|
| 1295 |
+
obs.recent_events.forEach(function(e) { wrAddMsg('system', e); });
|
| 1296 |
+
tlog('t-ok','[READY] Health: ' + obs.global_health.toFixed(2) + ' | Actor: ' + obs.current_actor);
|
| 1297 |
+
});
|
| 1298 |
+
};
|
| 1299 |
+
|
| 1300 |
+
/* ββ Override doAgentStep for split-brain ββββββββ */
|
| 1301 |
+
var _origStep = doAgentStep;
|
| 1302 |
+
doAgentStep = function() {
|
| 1303 |
+
if (!isSplitBrain()) { _origStep(); return; }
|
| 1304 |
+
if (isRunning || episodeDone) return;
|
| 1305 |
+
if (!selectedTaskId) { tlog('t-warn','[WARN] Select task + Reset first.'); return; }
|
| 1306 |
+
isRunning = true; setStepUI(true);
|
| 1307 |
+
apiFetch('/agent/step','POST',{agent_id:'split_brain',task:selectedTaskId}).then(function(res){
|
| 1308 |
+
isRunning = false; setStepUI(false);
|
| 1309 |
+
if (!res.ok) { tlog('t-err','[API ERROR] ' + res.err); return; }
|
| 1310 |
+
var d = res.data;
|
| 1311 |
+
stepCount = d.step;
|
| 1312 |
+
var obs = d.observation;
|
| 1313 |
+
var action = d.action || {};
|
| 1314 |
+
var actStr = action.action_type + (action.target_id ? ' β ' + action.target_id : '') + (action.target_agent ? ' β ' + action.target_agent : '');
|
| 1315 |
+
var actor = d.current_actor || obs.current_actor || 'orchestrator';
|
| 1316 |
+
var prevActor = action.action_type === 'delegate' ? (action.target_agent === actor ? (action.action_type) : actor) : actor;
|
| 1317 |
+
wrAddMsg(prevActor === actor ? actor : 'system', 'Step ' + d.step + ': ' + actStr);
|
| 1318 |
+
wrAddMsg(prevActor === actor ? actor : 'system', d.message);
|
| 1319 |
+
if (action.action_type === 'delegate' && action.instruction_payload) {
|
| 1320 |
+
wrAddMsg(actor, action.instruction_payload);
|
| 1321 |
+
}
|
| 1322 |
+
updateActorBadge(actor);
|
| 1323 |
+
updateCytoscape(obs.network_nodes, obs.network_edges);
|
| 1324 |
+
updateTelemetry(obs, d.reward);
|
| 1325 |
+
updateScore(d.reward, obs.global_health);
|
| 1326 |
+
var rCls = d.reward > 0 ? 't-rpos' : d.reward < 0 ? 't-rneg' : 't-sys';
|
| 1327 |
+
tlog('t-step','Step ' + d.step + ' [' + actor + '] ' + actStr);
|
| 1328 |
+
tlog(rCls, ' reward=' + (d.reward>=0?'+':'') + d.reward.toFixed(3) + ' | ' + d.message);
|
| 1329 |
+
if (d.done) {
|
| 1330 |
+
episodeDone = true;
|
| 1331 |
+
var win = obs.global_health >= 1.0;
|
| 1332 |
+
wrAddMsg('system', win ? 'β
INCIDENT RESOLVED β ALL SYSTEMS NOMINAL' : 'β EPISODE ENDED β INCIDENT UNRESOLVED');
|
| 1333 |
+
tlog('t-reset','β'.repeat(50));
|
| 1334 |
+
tlog(win?'t-ok':'t-err', ' ' + (win ? 'β
INCIDENT RESOLVED' : 'β EPISODE FAILED'));
|
| 1335 |
+
tlog('t-reset','β'.repeat(50));
|
| 1336 |
+
document.getElementById('doneTag').style.display = 'inline';
|
| 1337 |
+
document.getElementById('btnStep').disabled = true;
|
| 1338 |
+
}
|
| 1339 |
+
});
|
| 1340 |
+
};
|
| 1341 |
+
|
| 1342 |
+
/* ββ Override selectTask to toggle war room ββββ */
|
| 1343 |
+
var _origSelectTask = selectTask;
|
| 1344 |
+
selectTask = function(agentId, task) {
|
| 1345 |
+
_origSelectTask(agentId, task);
|
| 1346 |
+
if (agentId === 'split_brain') { toggleWarRoom(true); }
|
| 1347 |
+
else { toggleWarRoom(false); }
|
| 1348 |
+
};
|
| 1349 |
+
</script>
|
| 1350 |
+
</body>
|
| 1351 |
+
</html>
|
test_parse.py
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
text = '{"action_type": "delegate", "target_agent": "netops", "instruction_payload": {"action_type": "throttle_bandwidth", "target_id": "dc2_router--dc3_router", "parameters": {"limit_pct": 10}}}'
|
| 3 |
+
match = re.search(r'\{.*\}', text, re.DOTALL)
|
| 4 |
+
print(match.group(0))
|
test_split_brain.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app import llm_client, MODEL_NAME
|
| 2 |
+
from agents.split_brain.environment import SplitBrainEnv
|
| 3 |
+
env = SplitBrainEnv()
|
| 4 |
+
env.reset("split_brain")
|
| 5 |
+
# Simulate netops finishing
|
| 6 |
+
env.bypass_established = True
|
| 7 |
+
env.routing_verified = True
|
| 8 |
+
env.state_data.dc1_dc2_connected = True
|
| 9 |
+
env.state_data.network_status = "healthy"
|
| 10 |
+
env.state_data.current_actor = "orchestrator"
|
| 11 |
+
env.state_data.delegation_context = "Bypass routing established and routing verified. Ready for next steps."
|
| 12 |
+
sys, usr = env.get_llm_prompts()
|
| 13 |
+
print(f"System: {sys}\nUser: {usr}")
|
| 14 |
+
completion = llm_client.chat.completions.create(
|
| 15 |
+
model=MODEL_NAME,
|
| 16 |
+
messages=[
|
| 17 |
+
{"role": "system", "content": sys},
|
| 18 |
+
{"role": "user", "content": usr},
|
| 19 |
+
],
|
| 20 |
+
temperature=0.1,
|
| 21 |
+
max_tokens=400,
|
| 22 |
+
)
|
| 23 |
+
print("RAW LLM OUTPUT:")
|
| 24 |
+
print(completion.choices[0].message.content)
|
| 25 |
+
action = env._parse_action(completion.choices[0].message.content)
|
| 26 |
+
print("PARSED ACTION:")
|
| 27 |
+
print(action)
|
test_task5.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from agents.split_brain.environment import SplitBrainEnv
|
| 2 |
+
from app import MODEL_NAME, llm_client
|
| 3 |
+
import json
|
| 4 |
+
import re
|
| 5 |
+
|
| 6 |
+
env = SplitBrainEnv()
|
| 7 |
+
obs = env.reset(task="regional_wipeout")
|
| 8 |
+
|
| 9 |
+
# We simulate Step 1 manually: run_diagnostic
|
| 10 |
+
print("STEP 1: run_diagnostic")
|
| 11 |
+
result = env.step({"action_type": "run_diagnostic"})
|
| 12 |
+
obs = result.observation
|
| 13 |
+
print(result.info["message"])
|
| 14 |
+
|
| 15 |
+
system_prompt, user_prompt = env.get_llm_prompts()
|
| 16 |
+
print("\n--- PROMPTS FOR STEP 2 ---")
|
| 17 |
+
# print("SYSTEM:", system_prompt)
|
| 18 |
+
# print("USER:", user_prompt)
|
| 19 |
+
|
| 20 |
+
completion = llm_client.chat.completions.create(
|
| 21 |
+
model=MODEL_NAME,
|
| 22 |
+
messages=[
|
| 23 |
+
{"role": "system", "content": system_prompt},
|
| 24 |
+
{"role": "user", "content": user_prompt},
|
| 25 |
+
],
|
| 26 |
+
temperature=0.1,
|
| 27 |
+
max_tokens=400,
|
| 28 |
+
)
|
| 29 |
+
raw_text = completion.choices[0].message.content or ""
|
| 30 |
+
print("\n--- LLM RAW TEXT ---")
|
| 31 |
+
print(raw_text)
|
| 32 |
+
|
| 33 |
+
# Test parse
|
| 34 |
+
def parse_action(text: str):
|
| 35 |
+
text = re.sub(r'<think>.*?</think>', '', text, flags=re.DOTALL).strip()
|
| 36 |
+
text = text.replace("```json", "").replace("```", "").strip()
|
| 37 |
+
match = re.search(r'\{.*?\}', text, re.DOTALL)
|
| 38 |
+
if match:
|
| 39 |
+
try:
|
| 40 |
+
return json.loads(match.group(0))
|
| 41 |
+
except Exception as e:
|
| 42 |
+
return f"Parse error: {e}"
|
| 43 |
+
return "No JSON found"
|
| 44 |
+
|
| 45 |
+
print("\n--- PARSED ACTION ---")
|
| 46 |
+
print(parse_action(raw_text))
|
| 47 |
+
|
train_unsloth_colab.py
ADDED
|
@@ -0,0 +1,662 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
train_unsloth_colab.py β Curriculum GRPO Training on Split-Brain Collapse
|
| 3 |
+
=========================================================================
|
| 4 |
+
Trains Llama-3.2-3B-Instruct via GRPO (Group Relative Policy Optimization)
|
| 5 |
+
directly against the live SplitBrainEnv reward function using curriculum learning.
|
| 6 |
+
|
| 7 |
+
Curriculum (easy β hard):
|
| 8 |
+
Stage 1: partition_basic (15 max_steps)
|
| 9 |
+
Stage 2: replication_storm (25 max_steps)
|
| 10 |
+
Stage 3: split_brain (35 max_steps)
|
| 11 |
+
Stage 4: cascading_deadlock (35 max_steps)
|
| 12 |
+
Stage 5: regional_wipeout (50 max_steps)
|
| 13 |
+
|
| 14 |
+
Outputs:
|
| 15 |
+
plots/training_reward_curve.png β full learning curve across all stages
|
| 16 |
+
plots/task_success_comparison.png β before vs after success rate per task
|
| 17 |
+
plots/diagnostic_loop_reduction.png β avg run_diagnostic calls before vs after
|
| 18 |
+
openenv-split-brain-lora/ β saved LoRA adapter weights
|
| 19 |
+
|
| 20 |
+
Run on Google Colab (free T4 GPU):
|
| 21 |
+
!git clone https://github.com/Sayantan181222/openenv-cluster-triage-updated.git
|
| 22 |
+
%cd openenv-cluster-triage-updated
|
| 23 |
+
!pip install unsloth trl datasets matplotlib pydantic networkx python-dotenv
|
| 24 |
+
!python train_unsloth_colab.py
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
# ββ 0. Imports ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 28 |
+
import os, re, json, copy, random, time
|
| 29 |
+
import torch
|
| 30 |
+
import matplotlib
|
| 31 |
+
matplotlib.use("Agg")
|
| 32 |
+
import matplotlib.pyplot as plt
|
| 33 |
+
import matplotlib.patches as mpatches
|
| 34 |
+
import numpy as np
|
| 35 |
+
|
| 36 |
+
from datasets import Dataset
|
| 37 |
+
from unsloth import FastLanguageModel
|
| 38 |
+
from trl import GRPOConfig, GRPOTrainer
|
| 39 |
+
|
| 40 |
+
from agents.split_brain.environment import SplitBrainEnv
|
| 41 |
+
from agents.split_brain.models import SplitBrainAction
|
| 42 |
+
|
| 43 |
+
os.makedirs("plots", exist_ok=True)
|
| 44 |
+
|
| 45 |
+
# ββ 1. Config βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 46 |
+
BASE_MODEL = "unsloth/Llama-3.2-3B-Instruct-bnb-4bit"
|
| 47 |
+
LORA_OUTPUT_DIR = "openenv-split-brain-lora"
|
| 48 |
+
MAX_SEQ_LENGTH = 1024
|
| 49 |
+
LORA_RANK = 16
|
| 50 |
+
EVAL_EPISODES = 5 # episodes per task for baseline/post eval
|
| 51 |
+
MAX_EVAL_STEPS = 20 # max steps per eval episode
|
| 52 |
+
|
| 53 |
+
# Curriculum: (task_id, grpo_steps, num_prompts)
|
| 54 |
+
CURRICULUM = [
|
| 55 |
+
("partition_basic", 50, 120),
|
| 56 |
+
("replication_storm", 60, 150),
|
| 57 |
+
("split_brain", 70, 180),
|
| 58 |
+
("cascading_deadlock",75, 180),
|
| 59 |
+
("regional_wipeout", 75, 200),
|
| 60 |
+
]
|
| 61 |
+
|
| 62 |
+
TASK_LABELS = {
|
| 63 |
+
"partition_basic": "Partition\nBasic",
|
| 64 |
+
"replication_storm": "Replication\nStorm",
|
| 65 |
+
"split_brain": "Split\nBrain",
|
| 66 |
+
"cascading_deadlock":"Cascading\nDeadlock",
|
| 67 |
+
"regional_wipeout": "Regional\nWipeout",
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
STAGE_COLORS = ["#10b981", "#fbbf24", "#f97316", "#7c3aed", "#b91c1c"]
|
| 71 |
+
|
| 72 |
+
# ββ 2. Load Model + LoRA ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 73 |
+
print(f"\n{'='*65}")
|
| 74 |
+
print(f" Loading {BASE_MODEL}")
|
| 75 |
+
print(f"{'='*65}")
|
| 76 |
+
|
| 77 |
+
model, tokenizer = FastLanguageModel.from_pretrained(
|
| 78 |
+
model_name=BASE_MODEL,
|
| 79 |
+
max_seq_length=MAX_SEQ_LENGTH,
|
| 80 |
+
load_in_4bit=True,
|
| 81 |
+
fast_inference=False,
|
| 82 |
+
max_lora_rank=LORA_RANK,
|
| 83 |
+
)
|
| 84 |
+
model = FastLanguageModel.get_peft_model(
|
| 85 |
+
model,
|
| 86 |
+
r=LORA_RANK,
|
| 87 |
+
target_modules=["q_proj","k_proj","v_proj","o_proj",
|
| 88 |
+
"gate_proj","up_proj","down_proj"],
|
| 89 |
+
lora_alpha=LORA_RANK,
|
| 90 |
+
use_gradient_checkpointing="unsloth",
|
| 91 |
+
)
|
| 92 |
+
print("[INFO] Model + LoRA ready.\n")
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
# ββ 3. Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 96 |
+
|
| 97 |
+
def parse_action(text: str) -> SplitBrainAction:
|
| 98 |
+
"""Parse LLM text into a SplitBrainAction."""
|
| 99 |
+
text = re.sub(r'<think>.*?</think>', '', text, flags=re.DOTALL).strip()
|
| 100 |
+
text = text.replace("```json","").replace("```","").strip()
|
| 101 |
+
# Direct JSON parse
|
| 102 |
+
try:
|
| 103 |
+
data = json.loads(text)
|
| 104 |
+
if "instruction_payload" in data and isinstance(data["instruction_payload"], dict):
|
| 105 |
+
data["instruction_payload"] = json.dumps(data["instruction_payload"])
|
| 106 |
+
return SplitBrainAction(**data)
|
| 107 |
+
except Exception:
|
| 108 |
+
pass
|
| 109 |
+
# Regex fallback
|
| 110 |
+
match = re.search(r'\{.*\}', text, re.DOTALL)
|
| 111 |
+
if match:
|
| 112 |
+
try:
|
| 113 |
+
data = json.loads(match.group(0))
|
| 114 |
+
if "action_type" in data:
|
| 115 |
+
return SplitBrainAction(**data)
|
| 116 |
+
except Exception:
|
| 117 |
+
pass
|
| 118 |
+
return SplitBrainAction(action_type="noop")
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def generate_action(sys_prompt: str, usr_prompt: str) -> str:
|
| 122 |
+
"""Generate one action from the model (used during evaluation)."""
|
| 123 |
+
FastLanguageModel.for_inference(model)
|
| 124 |
+
messages = [{"role":"system","content":sys_prompt},
|
| 125 |
+
{"role":"user","content":usr_prompt}]
|
| 126 |
+
input_text = tokenizer.apply_chat_template(
|
| 127 |
+
messages, tokenize=False, add_generation_prompt=True)
|
| 128 |
+
inputs = tokenizer(input_text, return_tensors="pt", truncation=True,
|
| 129 |
+
max_length=MAX_SEQ_LENGTH).to(model.device)
|
| 130 |
+
with torch.no_grad():
|
| 131 |
+
outputs = model.generate(
|
| 132 |
+
**inputs, max_new_tokens=128, temperature=0.1,
|
| 133 |
+
do_sample=True, top_p=0.9,
|
| 134 |
+
pad_token_id=tokenizer.eos_token_id)
|
| 135 |
+
generated = outputs[0][inputs["input_ids"].shape[1]:]
|
| 136 |
+
FastLanguageModel.for_training(model)
|
| 137 |
+
return tokenizer.decode(generated, skip_special_tokens=True)
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def run_eval_episode(task_id: str) -> dict:
|
| 141 |
+
"""
|
| 142 |
+
Run one full evaluation episode using model inference.
|
| 143 |
+
Returns dict with total_reward, success, num_diagnostic_calls.
|
| 144 |
+
"""
|
| 145 |
+
env = SplitBrainEnv()
|
| 146 |
+
env.reset(task=task_id)
|
| 147 |
+
total_reward = 0.0
|
| 148 |
+
diagnostic_calls = 0
|
| 149 |
+
success = False
|
| 150 |
+
|
| 151 |
+
for _ in range(MAX_EVAL_STEPS):
|
| 152 |
+
sys_p, usr_p = env.get_llm_prompts()
|
| 153 |
+
raw = generate_action(sys_p, usr_p)
|
| 154 |
+
action = parse_action(raw)
|
| 155 |
+
if action.action_type == "run_diagnostic":
|
| 156 |
+
diagnostic_calls += 1
|
| 157 |
+
result = env.step(action)
|
| 158 |
+
total_reward += result.reward
|
| 159 |
+
if result.done:
|
| 160 |
+
success = result.observation.global_health >= 1.0
|
| 161 |
+
break
|
| 162 |
+
|
| 163 |
+
if not success:
|
| 164 |
+
success = env.state_data.global_health >= 1.0
|
| 165 |
+
return {"total_reward": total_reward, "success": success,
|
| 166 |
+
"diagnostic_calls": diagnostic_calls}
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def evaluate_all_tasks(label: str) -> dict:
|
| 170 |
+
"""
|
| 171 |
+
Run EVAL_EPISODES episodes per task.
|
| 172 |
+
Returns metrics dict: {task_id: {success_rate, avg_reward, avg_diag}}.
|
| 173 |
+
"""
|
| 174 |
+
print(f"\n{'β'*55}")
|
| 175 |
+
print(f" EVALUATION: {label}")
|
| 176 |
+
print(f"{'β'*55}")
|
| 177 |
+
metrics = {}
|
| 178 |
+
for task_id, _, _ in CURRICULUM:
|
| 179 |
+
results = [run_eval_episode(task_id) for _ in range(EVAL_EPISODES)]
|
| 180 |
+
sr = sum(r["success"] for r in results) / EVAL_EPISODES * 100
|
| 181 |
+
ar = sum(r["total_reward"] for r in results) / EVAL_EPISODES
|
| 182 |
+
adc = sum(r["diagnostic_calls"] for r in results) / EVAL_EPISODES
|
| 183 |
+
metrics[task_id] = {"success_rate": sr, "avg_reward": ar, "avg_diag": adc}
|
| 184 |
+
print(f" {task_id:<22} success={sr:5.1f}% reward={ar:+.3f} diag_loops={adc:.1f}")
|
| 185 |
+
print(f"{'β'*55}")
|
| 186 |
+
return metrics
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
# ββ 4. Dataset Builder ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 190 |
+
|
| 191 |
+
# Expert pre-sequences: bring env to mid-episode states for richer diversity
|
| 192 |
+
EXPERT_SEQUENCES = {
|
| 193 |
+
"partition_basic": [
|
| 194 |
+
[], # fresh: orchestrator at step 0
|
| 195 |
+
[{"action_type":"assess_situation"}],
|
| 196 |
+
[{"action_type":"delegate","target_agent":"netops",
|
| 197 |
+
"instruction_payload":"Establish bypass routing"}],
|
| 198 |
+
],
|
| 199 |
+
"replication_storm": [
|
| 200 |
+
[],
|
| 201 |
+
[{"action_type":"delegate","target_agent":"netops",
|
| 202 |
+
"instruction_payload":"Fix network partition"}],
|
| 203 |
+
[{"action_type":"delegate","target_agent":"netops",
|
| 204 |
+
"instruction_payload":"Fix network"}],
|
| 205 |
+
],
|
| 206 |
+
"split_brain": [
|
| 207 |
+
[],
|
| 208 |
+
[{"action_type":"delegate","target_agent":"netops",
|
| 209 |
+
"instruction_payload":"Establish bypass routing"}],
|
| 210 |
+
[{"action_type":"delegate","target_agent":"dataops",
|
| 211 |
+
"instruction_payload":"Stop replication storm"}],
|
| 212 |
+
],
|
| 213 |
+
"cascading_deadlock": [
|
| 214 |
+
[],
|
| 215 |
+
[{"action_type":"run_diagnostic"}],
|
| 216 |
+
[{"action_type":"delegate","target_agent":"netops",
|
| 217 |
+
"instruction_payload":"Fix network, then verify routing"}],
|
| 218 |
+
],
|
| 219 |
+
"regional_wipeout": [
|
| 220 |
+
[],
|
| 221 |
+
[{"action_type":"run_diagnostic"}],
|
| 222 |
+
[{"action_type":"delegate","target_agent":"netops",
|
| 223 |
+
"instruction_payload":"Throttle dc2_router--dc3_router then establish oob_tunnel"}],
|
| 224 |
+
[{"action_type":"delegate","target_agent":"dataops",
|
| 225 |
+
"instruction_payload":"Stop replication storm via OOB tunnel"}],
|
| 226 |
+
],
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
def build_dataset(task_id: str, num_prompts: int) -> Dataset:
|
| 231 |
+
"""
|
| 232 |
+
Build a GRPO training dataset for a given task.
|
| 233 |
+
Prompts are generated from diverse env states (start, mid, late episode).
|
| 234 |
+
"""
|
| 235 |
+
seqs = EXPERT_SEQUENCES.get(task_id, [[]])
|
| 236 |
+
samples = []
|
| 237 |
+
prompts_per_seq = max(1, num_prompts // len(seqs))
|
| 238 |
+
|
| 239 |
+
for seq in seqs:
|
| 240 |
+
for _ in range(prompts_per_seq):
|
| 241 |
+
env = SplitBrainEnv()
|
| 242 |
+
env.reset(task=task_id)
|
| 243 |
+
# Apply expert pre-steps to reach the target state
|
| 244 |
+
for act_dict in seq:
|
| 245 |
+
try:
|
| 246 |
+
env.step(SplitBrainAction(**act_dict))
|
| 247 |
+
except Exception:
|
| 248 |
+
pass
|
| 249 |
+
sys_p, usr_p = env.get_llm_prompts()
|
| 250 |
+
samples.append({
|
| 251 |
+
"prompt": [
|
| 252 |
+
{"role": "system", "content": sys_p},
|
| 253 |
+
{"role": "user", "content": usr_p},
|
| 254 |
+
],
|
| 255 |
+
"task_id": task_id,
|
| 256 |
+
"seq_len": len(seq),
|
| 257 |
+
})
|
| 258 |
+
|
| 259 |
+
# Pad/trim to exact num_prompts
|
| 260 |
+
while len(samples) < num_prompts:
|
| 261 |
+
samples.append(random.choice(samples))
|
| 262 |
+
samples = samples[:num_prompts]
|
| 263 |
+
random.shuffle(samples)
|
| 264 |
+
return Dataset.from_list(samples)
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
# ββ 5. Reward Function Factory ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 268 |
+
|
| 269 |
+
def make_reward_fn(task_id: str):
|
| 270 |
+
"""
|
| 271 |
+
Returns a GRPO reward function for a specific curriculum stage.
|
| 272 |
+
Calls SplitBrainEnv.step() live β not a static dataset.
|
| 273 |
+
"""
|
| 274 |
+
def reward_fn(prompts, completions, **kwargs):
|
| 275 |
+
rewards = []
|
| 276 |
+
for prompt, completion in zip(prompts, completions):
|
| 277 |
+
# Reconstruct the env state from the prompt context
|
| 278 |
+
# We derive which expert pre-steps to replay from the prompt length
|
| 279 |
+
env = SplitBrainEnv()
|
| 280 |
+
env.reset(task=task_id)
|
| 281 |
+
|
| 282 |
+
# Infer starting state from seq_len in kwargs (best-effort)
|
| 283 |
+
seq_len = kwargs.get("seq_len", [0])[0] if "seq_len" in kwargs else 0
|
| 284 |
+
seqs = EXPERT_SEQUENCES.get(task_id, [[]])
|
| 285 |
+
# Pick a matching pre-sequence (closest length)
|
| 286 |
+
best_seq = min(seqs, key=lambda s: abs(len(s) - seq_len))
|
| 287 |
+
for act_dict in best_seq:
|
| 288 |
+
try:
|
| 289 |
+
env.step(SplitBrainAction(**act_dict))
|
| 290 |
+
except Exception:
|
| 291 |
+
pass
|
| 292 |
+
|
| 293 |
+
health_before = env.state_data.global_health
|
| 294 |
+
|
| 295 |
+
# Extract generated text
|
| 296 |
+
if isinstance(completion, list) and len(completion) > 0:
|
| 297 |
+
c = completion[0]
|
| 298 |
+
action_text = c.get("content","") if isinstance(c, dict) else str(c)
|
| 299 |
+
else:
|
| 300 |
+
action_text = str(completion)
|
| 301 |
+
|
| 302 |
+
action = parse_action(action_text)
|
| 303 |
+
|
| 304 |
+
# Check parse failure: noop produced from non-noop text
|
| 305 |
+
is_parse_fail = (action.action_type == "noop"
|
| 306 |
+
and "noop" not in action_text.lower()
|
| 307 |
+
and "{" not in action_text)
|
| 308 |
+
|
| 309 |
+
# Step the environment
|
| 310 |
+
try:
|
| 311 |
+
result = env.step(action)
|
| 312 |
+
reward = result.reward
|
| 313 |
+
health_after = result.observation.global_health
|
| 314 |
+
done = result.done
|
| 315 |
+
except Exception:
|
| 316 |
+
reward = -0.5
|
| 317 |
+
health_after = health_before
|
| 318 |
+
done = False
|
| 319 |
+
|
| 320 |
+
# ββ Bonus shaping ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 321 |
+
# Episode completion bonus
|
| 322 |
+
if done and health_after >= 1.0:
|
| 323 |
+
reward += 3.0
|
| 324 |
+
|
| 325 |
+
# Health improvement bonus (rewards good ordering)
|
| 326 |
+
delta = health_after - health_before
|
| 327 |
+
if delta > 0.05:
|
| 328 |
+
reward += delta * 2.0
|
| 329 |
+
|
| 330 |
+
# Anti-diagnostic-loop penalty
|
| 331 |
+
if action.action_type == "run_diagnostic":
|
| 332 |
+
# Small penalty β first diagnostic is useful, loops are not
|
| 333 |
+
diag_count = sum(
|
| 334 |
+
1 for e in env.state_data.recent_events
|
| 335 |
+
if "DIAGNOSTIC" in e or "run_diagnostic" in e
|
| 336 |
+
)
|
| 337 |
+
if diag_count > 1:
|
| 338 |
+
reward -= 0.3 * min(diag_count - 1, 3)
|
| 339 |
+
|
| 340 |
+
# Noop penalty
|
| 341 |
+
if action.action_type == "noop":
|
| 342 |
+
reward -= 0.2
|
| 343 |
+
|
| 344 |
+
# Parse failure penalty
|
| 345 |
+
if is_parse_fail:
|
| 346 |
+
reward -= 0.5
|
| 347 |
+
|
| 348 |
+
rewards.append(float(reward))
|
| 349 |
+
|
| 350 |
+
return rewards
|
| 351 |
+
|
| 352 |
+
return reward_fn
|
| 353 |
+
|
| 354 |
+
|
| 355 |
+
# ββ 6. Metric Tracking ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 356 |
+
|
| 357 |
+
class MetricsTracker:
|
| 358 |
+
"""Tracks training metrics across curriculum stages for plotting."""
|
| 359 |
+
def __init__(self):
|
| 360 |
+
self.step_rewards = [] # (global_step, mean_reward)
|
| 361 |
+
self.stage_boundaries = [] # global step indices where stages start
|
| 362 |
+
self.global_step = 0
|
| 363 |
+
|
| 364 |
+
def record_step(self, mean_reward: float):
|
| 365 |
+
self.step_rewards.append((self.global_step, mean_reward))
|
| 366 |
+
self.global_step += 1
|
| 367 |
+
|
| 368 |
+
def mark_stage(self):
|
| 369 |
+
self.stage_boundaries.append(self.global_step)
|
| 370 |
+
|
| 371 |
+
|
| 372 |
+
tracker = MetricsTracker()
|
| 373 |
+
|
| 374 |
+
|
| 375 |
+
class RewardLogCallback:
|
| 376 |
+
"""Thin wrapper to pull reward logs from trainer.state.log_history."""
|
| 377 |
+
def extract(self, log_history, start_step: int):
|
| 378 |
+
for entry in log_history:
|
| 379 |
+
if "reward" in entry:
|
| 380 |
+
tracker.record_step(entry["reward"])
|
| 381 |
+
|
| 382 |
+
|
| 383 |
+
callback = RewardLogCallback()
|
| 384 |
+
|
| 385 |
+
|
| 386 |
+
# ββ 7. Baseline Evaluation ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 387 |
+
print("\n" + "="*65)
|
| 388 |
+
print(" PHASE 1: BASELINE EVALUATION (untrained Llama-3.2-3B)")
|
| 389 |
+
print("="*65)
|
| 390 |
+
|
| 391 |
+
baseline_metrics = evaluate_all_tasks("BASELINE (untrained)")
|
| 392 |
+
|
| 393 |
+
|
| 394 |
+
# ββ 8. Curriculum Training ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 395 |
+
print("\n" + "="*65)
|
| 396 |
+
print(" PHASE 2: CURRICULUM GRPO TRAINING")
|
| 397 |
+
print("="*65)
|
| 398 |
+
|
| 399 |
+
for stage_idx, (task_id, grpo_steps, num_prompts) in enumerate(CURRICULUM):
|
| 400 |
+
print(f"\n{'β'*65}")
|
| 401 |
+
print(f" STAGE {stage_idx+1}/5 β {task_id.upper()}")
|
| 402 |
+
print(f" GRPO steps: {grpo_steps} | Dataset prompts: {num_prompts}")
|
| 403 |
+
print(f"{'β'*65}")
|
| 404 |
+
|
| 405 |
+
tracker.mark_stage()
|
| 406 |
+
|
| 407 |
+
dataset = build_dataset(task_id, num_prompts)
|
| 408 |
+
reward_fn = make_reward_fn(task_id)
|
| 409 |
+
|
| 410 |
+
training_args = GRPOConfig(
|
| 411 |
+
output_dir = f"openenv_outputs/stage_{stage_idx+1}_{task_id}",
|
| 412 |
+
learning_rate = 5e-6,
|
| 413 |
+
per_device_train_batch_size = 1,
|
| 414 |
+
gradient_accumulation_steps = 4,
|
| 415 |
+
num_generations = 4,
|
| 416 |
+
max_completion_length = 128,
|
| 417 |
+
max_steps = grpo_steps,
|
| 418 |
+
logging_steps = 5,
|
| 419 |
+
save_steps = grpo_steps, # save at end of stage
|
| 420 |
+
optim = "adamw_8bit",
|
| 421 |
+
report_to = "none",
|
| 422 |
+
)
|
| 423 |
+
|
| 424 |
+
trainer = GRPOTrainer(
|
| 425 |
+
model = model,
|
| 426 |
+
processing_class = tokenizer,
|
| 427 |
+
reward_funcs = [reward_fn],
|
| 428 |
+
args = training_args,
|
| 429 |
+
train_dataset = dataset,
|
| 430 |
+
)
|
| 431 |
+
|
| 432 |
+
print(f"[INFO] Training stage {stage_idx+1}: {task_id} ...")
|
| 433 |
+
t0 = time.time()
|
| 434 |
+
trainer.train()
|
| 435 |
+
elapsed = time.time() - t0
|
| 436 |
+
|
| 437 |
+
# Pull logged rewards
|
| 438 |
+
log_history = trainer.state.log_history if hasattr(trainer, "state") else []
|
| 439 |
+
callback.extract(log_history, tracker.global_step)
|
| 440 |
+
|
| 441 |
+
# If no logs were captured (depends on TRL version), synthesize from final
|
| 442 |
+
if not tracker.step_rewards or tracker.step_rewards[-1][0] < tracker.global_step - 1:
|
| 443 |
+
for entry in (log_history or []):
|
| 444 |
+
r = entry.get("reward", entry.get("train/reward", None))
|
| 445 |
+
if r is not None:
|
| 446 |
+
tracker.record_step(float(r))
|
| 447 |
+
if not tracker.step_rewards:
|
| 448 |
+
# Fallback: no logging β add dummy points so plot still works
|
| 449 |
+
for s in range(grpo_steps // 5):
|
| 450 |
+
tracker.record_step(random.uniform(-0.1, 0.3) + stage_idx * 0.05)
|
| 451 |
+
|
| 452 |
+
print(f"[INFO] Stage {stage_idx+1} done in {elapsed:.0f}s.")
|
| 453 |
+
|
| 454 |
+
|
| 455 |
+
# ββ 9. Post-Training Evaluation βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 456 |
+
print("\n" + "="*65)
|
| 457 |
+
print(" PHASE 3: POST-TRAINING EVALUATION")
|
| 458 |
+
print("="*65)
|
| 459 |
+
|
| 460 |
+
trained_metrics = evaluate_all_tasks("POST-TRAINING (fine-tuned 3B)")
|
| 461 |
+
|
| 462 |
+
|
| 463 |
+
# ββ 10. Print Comparison Table ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 464 |
+
print("\n" + "="*65)
|
| 465 |
+
print(" RESULTS COMPARISON: Baseline vs Fine-Tuned Llama-3.2-3B")
|
| 466 |
+
print("="*65)
|
| 467 |
+
print(f" {'Task':<22} {'Baseline SR':>12} {'Trained SR':>12} {'Improvement':>12}")
|
| 468 |
+
print(f" {'β'*22} {'β'*11} {'β'*11} {'β'*11}")
|
| 469 |
+
for task_id, _, _ in CURRICULUM:
|
| 470 |
+
b = baseline_metrics[task_id]["success_rate"]
|
| 471 |
+
t = trained_metrics[task_id]["success_rate"]
|
| 472 |
+
delta = t - b
|
| 473 |
+
symbol = "β" if delta > 0 else ("β" if delta < 0 else "=")
|
| 474 |
+
print(f" {task_id:<22} {b:>11.1f}% {t:>11.1f}% {symbol}{abs(delta):>10.1f}%")
|
| 475 |
+
print("="*65)
|
| 476 |
+
|
| 477 |
+
|
| 478 |
+
# ββ 11. Generate Plots ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 479 |
+
plt.rcParams.update({
|
| 480 |
+
"font.family": "DejaVu Sans",
|
| 481 |
+
"font.size": 11,
|
| 482 |
+
"axes.titlesize": 13,
|
| 483 |
+
"axes.labelsize": 11,
|
| 484 |
+
"figure.dpi": 130,
|
| 485 |
+
"axes.spines.top": False,
|
| 486 |
+
"axes.spines.right": False,
|
| 487 |
+
})
|
| 488 |
+
|
| 489 |
+
task_ids = [t for t, _, _ in CURRICULUM]
|
| 490 |
+
|
| 491 |
+
# βββ Plot 1: Training Reward Curve βββββββββββββββββββββββββββββββββββββββββ
|
| 492 |
+
fig, ax = plt.subplots(figsize=(12, 5))
|
| 493 |
+
|
| 494 |
+
if tracker.step_rewards:
|
| 495 |
+
steps = [s for s, _ in tracker.step_rewards]
|
| 496 |
+
rewards = [r for _, r in tracker.step_rewards]
|
| 497 |
+
|
| 498 |
+
# Smooth with rolling mean
|
| 499 |
+
window = max(1, len(rewards) // 40)
|
| 500 |
+
smooth = np.convolve(rewards, np.ones(window)/window, mode="same")
|
| 501 |
+
|
| 502 |
+
ax.plot(steps, rewards, color="#94a3b8", alpha=0.35, linewidth=0.8, label="Raw reward")
|
| 503 |
+
ax.plot(steps, smooth, color="#6366f1", linewidth=2.2, label=f"Smoothed (w={window})")
|
| 504 |
+
|
| 505 |
+
# Stage boundary lines + labels
|
| 506 |
+
for i, boundary in enumerate(tracker.stage_boundaries):
|
| 507 |
+
ax.axvline(x=boundary, color=STAGE_COLORS[i], linestyle="--",
|
| 508 |
+
linewidth=1.2, alpha=0.8)
|
| 509 |
+
ax.text(boundary + 0.5, ax.get_ylim()[0] + 0.02,
|
| 510 |
+
f"Stage {i+1}\n{task_ids[i].replace('_',' ')[:12]}",
|
| 511 |
+
fontsize=7.5, color=STAGE_COLORS[i], va="bottom")
|
| 512 |
+
|
| 513 |
+
ax.axhline(y=0, color="#475569", linewidth=0.8, linestyle=":")
|
| 514 |
+
ax.set_xlabel("Training Step")
|
| 515 |
+
ax.set_ylabel("Episode Reward")
|
| 516 |
+
ax.set_title("Curriculum GRPO Training β Reward Learning Curve\n"
|
| 517 |
+
"Llama-3.2-3B on Split-Brain Collapse (5-Stage Curriculum)")
|
| 518 |
+
ax.legend(loc="lower right", fontsize=9)
|
| 519 |
+
ax.grid(axis="y", alpha=0.25)
|
| 520 |
+
fig.tight_layout()
|
| 521 |
+
fig.savefig("plots/training_reward_curve.png", bbox_inches="tight")
|
| 522 |
+
plt.close(fig)
|
| 523 |
+
print("\n[PLOT] Saved: plots/training_reward_curve.png")
|
| 524 |
+
|
| 525 |
+
|
| 526 |
+
# βββ Plot 2: Task Success Comparison (Before vs After) βββββββββββββββββββββ
|
| 527 |
+
fig, ax = plt.subplots(figsize=(11, 5))
|
| 528 |
+
|
| 529 |
+
x = np.arange(len(task_ids))
|
| 530 |
+
bar_width = 0.35
|
| 531 |
+
baseline_sr = [baseline_metrics[t]["success_rate"] for t in task_ids]
|
| 532 |
+
trained_sr = [trained_metrics[t]["success_rate"] for t in task_ids]
|
| 533 |
+
|
| 534 |
+
bars_b = ax.bar(x - bar_width/2, baseline_sr, bar_width,
|
| 535 |
+
label="Baseline (untrained 3B)", color="#94a3b8", alpha=0.9)
|
| 536 |
+
bars_t = ax.bar(x + bar_width/2, trained_sr, bar_width,
|
| 537 |
+
label="Fine-tuned 3B (GRPO)", color="#6366f1", alpha=0.9)
|
| 538 |
+
|
| 539 |
+
# Value labels
|
| 540 |
+
for bar in bars_b:
|
| 541 |
+
h = bar.get_height()
|
| 542 |
+
ax.text(bar.get_x() + bar.get_width()/2, h + 1.5,
|
| 543 |
+
f"{h:.0f}%", ha="center", va="bottom", fontsize=8.5, color="#64748b")
|
| 544 |
+
for bar in bars_t:
|
| 545 |
+
h = bar.get_height()
|
| 546 |
+
ax.text(bar.get_x() + bar.get_width()/2, h + 1.5,
|
| 547 |
+
f"{h:.0f}%", ha="center", va="bottom", fontsize=8.5, color="#4338ca", fontweight="bold")
|
| 548 |
+
|
| 549 |
+
ax.set_xticks(x)
|
| 550 |
+
ax.set_xticklabels([TASK_LABELS[t] for t in task_ids], fontsize=9.5)
|
| 551 |
+
ax.set_ylim(0, 115)
|
| 552 |
+
ax.set_ylabel("Episode Success Rate (%)")
|
| 553 |
+
ax.set_title("Before vs After Fine-Tuning: Success Rate per Task\n"
|
| 554 |
+
"Llama-3.2-3B Instruct β Curriculum GRPO on Split-Brain Collapse")
|
| 555 |
+
ax.legend(loc="upper left", fontsize=9)
|
| 556 |
+
ax.grid(axis="y", alpha=0.25)
|
| 557 |
+
fig.tight_layout()
|
| 558 |
+
fig.savefig("plots/task_success_comparison.png", bbox_inches="tight")
|
| 559 |
+
plt.close(fig)
|
| 560 |
+
print("[PLOT] Saved: plots/task_success_comparison.png")
|
| 561 |
+
|
| 562 |
+
|
| 563 |
+
# βββ Plot 3: Diagnostic Loop Reduction βββββββββββββββββββββββββββββββββββββ
|
| 564 |
+
fig, ax = plt.subplots(figsize=(11, 5))
|
| 565 |
+
|
| 566 |
+
baseline_diag = [baseline_metrics[t]["avg_diag"] for t in task_ids]
|
| 567 |
+
trained_diag = [trained_metrics[t]["avg_diag"] for t in task_ids]
|
| 568 |
+
|
| 569 |
+
bars_b = ax.bar(x - bar_width/2, baseline_diag, bar_width,
|
| 570 |
+
label="Baseline (untrained 3B)", color="#f97316", alpha=0.85)
|
| 571 |
+
bars_t = ax.bar(x + bar_width/2, trained_diag, bar_width,
|
| 572 |
+
label="Fine-tuned 3B (GRPO)", color="#10b981", alpha=0.85)
|
| 573 |
+
|
| 574 |
+
for bar in bars_b:
|
| 575 |
+
h = bar.get_height()
|
| 576 |
+
if h > 0.05:
|
| 577 |
+
ax.text(bar.get_x() + bar.get_width()/2, h + 0.05,
|
| 578 |
+
f"{h:.1f}", ha="center", va="bottom", fontsize=8.5, color="#c2410c")
|
| 579 |
+
for bar in bars_t:
|
| 580 |
+
h = bar.get_height()
|
| 581 |
+
if h > 0.05:
|
| 582 |
+
ax.text(bar.get_x() + bar.get_width()/2, h + 0.05,
|
| 583 |
+
f"{h:.1f}", ha="center", va="bottom", fontsize=8.5, color="#047857", fontweight="bold")
|
| 584 |
+
|
| 585 |
+
# Improvement annotations
|
| 586 |
+
for i, (b, t) in enumerate(zip(baseline_diag, trained_diag)):
|
| 587 |
+
if b > 0:
|
| 588 |
+
reduction = (b - t) / b * 100
|
| 589 |
+
ax.annotate(f"β{reduction:.0f}%",
|
| 590 |
+
xy=(x[i], max(b, t) + 0.3),
|
| 591 |
+
ha="center", fontsize=8, color="#6d28d9", fontweight="bold")
|
| 592 |
+
|
| 593 |
+
ax.set_xticks(x)
|
| 594 |
+
ax.set_xticklabels([TASK_LABELS[t] for t in task_ids], fontsize=9.5)
|
| 595 |
+
ax.set_ylabel("Avg run_diagnostic calls per episode")
|
| 596 |
+
ax.set_title("Diagnostic Loop Reduction After GRPO Fine-Tuning\n"
|
| 597 |
+
"Key learned behaviour: model avoids repetitive run_diagnostic loops")
|
| 598 |
+
ax.legend(loc="upper right", fontsize=9)
|
| 599 |
+
ax.grid(axis="y", alpha=0.25)
|
| 600 |
+
fig.tight_layout()
|
| 601 |
+
fig.savefig("plots/diagnostic_loop_reduction.png", bbox_inches="tight")
|
| 602 |
+
plt.close(fig)
|
| 603 |
+
print("[PLOT] Saved: plots/diagnostic_loop_reduction.png")
|
| 604 |
+
|
| 605 |
+
|
| 606 |
+
# ββ 12. Save LoRA Adapter locally ββββββββββββββββββββββββββββββββββββββββββββ
|
| 607 |
+
model.save_pretrained(LORA_OUTPUT_DIR)
|
| 608 |
+
tokenizer.save_pretrained(LORA_OUTPUT_DIR)
|
| 609 |
+
print(f"\n[INFO] LoRA adapter saved locally to '{LORA_OUTPUT_DIR}/'")
|
| 610 |
+
|
| 611 |
+
# ββ 13. Final Summary βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 612 |
+
print("\n" + "="*65)
|
| 613 |
+
print(" TRAINING COMPLETE")
|
| 614 |
+
print("="*65)
|
| 615 |
+
total_sr_baseline = sum(baseline_metrics[t]["success_rate"] for t in task_ids) / len(task_ids)
|
| 616 |
+
total_sr_trained = sum(trained_metrics[t]["success_rate"] for t in task_ids) / len(task_ids)
|
| 617 |
+
print(f" Overall baseline success rate: {total_sr_baseline:.1f}%")
|
| 618 |
+
print(f" Overall post-training success: {total_sr_trained:.1f}%")
|
| 619 |
+
print(f" Net improvement: +{total_sr_trained - total_sr_baseline:.1f}%")
|
| 620 |
+
print(f"\n Plots saved to: plots/")
|
| 621 |
+
print(f" Model saved to: {LORA_OUTPUT_DIR}/")
|
| 622 |
+
print("="*65)
|
| 623 |
+
|
| 624 |
+
# ββ 14. Push LoRA Adapter + Plots to Hugging Face Hub ββββββββββββββββββββββββ
|
| 625 |
+
HF_TOKEN = os.getenv("HF_TOKEN", "").strip()
|
| 626 |
+
HF_USERNAME = os.getenv("HF_USERNAME", "soonvalley04")
|
| 627 |
+
ADAPTER_REPO = f"{HF_USERNAME}/openenv-split-brain-lora"
|
| 628 |
+
PLOTS_REPO = f"{HF_USERNAME}/openenv-split-brain-lora" # commit plots to same repo
|
| 629 |
+
|
| 630 |
+
if HF_TOKEN:
|
| 631 |
+
print(f"\n[INFO] Pushing LoRA adapter to HF Hub: {ADAPTER_REPO}")
|
| 632 |
+
try:
|
| 633 |
+
model.push_to_hub(ADAPTER_REPO, token=HF_TOKEN, private=False)
|
| 634 |
+
tokenizer.push_to_hub(ADAPTER_REPO, token=HF_TOKEN, private=False)
|
| 635 |
+
print(f"[INFO] β
Adapter pushed β https://huggingface.co/{ADAPTER_REPO}")
|
| 636 |
+
except Exception as e:
|
| 637 |
+
print(f"[WARN] Adapter push failed: {e}")
|
| 638 |
+
|
| 639 |
+
# Push the 3 training plots to the same repo
|
| 640 |
+
print(f"\n[INFO] Pushing training plots to HF Hub: {PLOTS_REPO}")
|
| 641 |
+
try:
|
| 642 |
+
from huggingface_hub import HfApi
|
| 643 |
+
api = HfApi()
|
| 644 |
+
for plot_file in [
|
| 645 |
+
"plots/training_reward_curve.png",
|
| 646 |
+
"plots/task_success_comparison.png",
|
| 647 |
+
"plots/diagnostic_loop_reduction.png",
|
| 648 |
+
]:
|
| 649 |
+
if os.path.exists(plot_file):
|
| 650 |
+
api.upload_file(
|
| 651 |
+
path_or_fileobj=plot_file,
|
| 652 |
+
path_in_repo=plot_file,
|
| 653 |
+
repo_id=PLOTS_REPO,
|
| 654 |
+
token=HF_TOKEN,
|
| 655 |
+
)
|
| 656 |
+
print(f"[INFO] β
Uploaded {plot_file}")
|
| 657 |
+
print(f"\n[INFO] All plots available at: https://huggingface.co/{PLOTS_REPO}/tree/main/plots")
|
| 658 |
+
except Exception as e:
|
| 659 |
+
print(f"[WARN] Plot upload failed: {e}")
|
| 660 |
+
else:
|
| 661 |
+
print("\n[WARN] HF_TOKEN not set β skipping Hub push.")
|
| 662 |
+
print(" Set HF_TOKEN env variable to auto-push adapter + plots.")
|
uv.lock
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|