diff --git a/02-use-cases/openvla-agentcore-orchestrator/.env.example b/02-use-cases/openvla-agentcore-orchestrator/.env.example new file mode 100644 index 000000000..286398bba --- /dev/null +++ b/02-use-cases/openvla-agentcore-orchestrator/.env.example @@ -0,0 +1,24 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: MIT-0 +# +# Copy this file to .env and fill in your cluster details. +# Both slurm_mcp_server.py and vla_training_agent.py read from these. + +# Cluster SSH connection +CLUSTER_HOST=your-cluster-login-node.example.com +CLUSTER_USER=your-username +SSH_KEY_PATH=~/.ssh/id_rsa + +# Workspace paths on the cluster (FSx) +VLA_HOME=/fsx/$USER/vla + +# Training configuration +MAX_STEPS=500 +LEARNING_RATE=5e-4 +BATCH_SIZE=16 +LORA_RANK=32 +DATASET_NAME=libero_10_no_noops + +# Weights & Biases (set to 'online' to enable logging) +WANDB_MODE=disabled +WANDB_ENTITY= diff --git a/02-use-cases/openvla-agentcore-orchestrator/.gitignore b/02-use-cases/openvla-agentcore-orchestrator/.gitignore new file mode 100644 index 000000000..40c09c067 --- /dev/null +++ b/02-use-cases/openvla-agentcore-orchestrator/.gitignore @@ -0,0 +1,16 @@ +# Python +__pycache__/ +*.pyc +*.pyo +.venv/ +venv/ + +# Environment +.env + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log diff --git a/02-use-cases/openvla-agentcore-orchestrator/README.md b/02-use-cases/openvla-agentcore-orchestrator/README.md new file mode 100644 index 000000000..3a5c7f52e --- /dev/null +++ b/02-use-cases/openvla-agentcore-orchestrator/README.md @@ -0,0 +1,220 @@ +# OpenVLA AgentCore Orchestrator — Autonomous Training on HyperPod Slurm + +## Overview + +The ML Training Agent demonstrates how Amazon Bedrock AgentCore can autonomously manage GPU training jobs on a SageMaker HyperPod Slurm cluster. It uses a Slurm MCP server to expose cluster operations as tools, and an autonomous agent that submits OpenVLA fine-tuning jobs, monitors training metrics, detects anomalies (loss divergence, stalls, NaN), and takes corrective action — all without human intervention. + +The concrete workload is [OpenVLA-7B](https://github.com/openvla/openvla) LoRA fine-tuning on the LIBERO robotics benchmark, but the MCP server and agent pattern are workload-agnostic. + +### Use case details + +| Information | Details | +|---------------------|----------------------------------------------------------------------| +| Use case type | autonomous orchestration | +| Agent type | Single-agent with MCP tools | +| Use case components | MCP tools (Slurm operations over SSH), anomaly detection, recovery | +| Use case vertical | MLOps / ML Training | +| Example complexity | Intermediate | +| SDK used | MCP (Model Context Protocol) | + +### Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Local Machine / AgentCore Runtime │ +│ │ +│ ┌──────────────────┐ ┌─────────────────────────────┐ │ +│ │ VLA Training │ │ Slurm MCP Server │ │ +│ │ Agent │──────▶│ (6 tools: submit, status, │ │ +│ │ │ MCP │ logs, cancel, info, metrics)│ │ +│ └──────────────────┘ └──────────────┬──────────────┘ │ +└─────────────────────────────────────────────┼───────────────┘ + │ SSH + ▼ + ┌──────────────────────────────┐ + │ HyperPod Slurm Cluster │ + │ (P5/P5en nodes, FSx, EFA) │ + │ │ + │ sbatch → torchrun → GPUs │ + └──────────────────────────────┘ +``` + +### Key Features + +- **MCP-based Slurm integration**: 6 tools (submit, status, logs, cancel, cluster info, metrics) exposed via the Model Context Protocol +- **Anomaly detection**: Monitors loss curves for divergence, stalls, and NaN values +- **Automatic recovery**: Cancels failing jobs, adjusts learning rate, resubmits +- **Input validation**: Job IDs validated against injection; SSH with configurable keys +- **Container-based training**: Pinned Dockerfile with EFA/NCCL for reproducibility +- **HyperPod auto-resume**: Handles node failures via `srun --auto-resume` +- **Environment-driven config**: All paths/credentials via `.env` file — no hardcoded values + +## Prerequisites + +| Requirement | Description | +|-------------|-------------| +| Python 3.10+ | For the MCP server (`mcp` library requires 3.10+) | +| SageMaker HyperPod cluster | Slurm scheduler with P5/P5en GPU nodes | +| SSH access | Key-based SSH to the cluster login node | +| FSx for Lustre | Shared filesystem mounted at `/fsx` | +| Docker + Enroot/Pyxis | For building and running the training container | + +## File Structure + +``` +openvla-agentcore-orchestrator/ +├── README.md # This file +├── .env.example # Configuration template +├── .gitignore +├── requirements.txt # MCP server dependency +├── slurm_mcp_server.py # MCP server (6 Slurm tools over SSH) +├── vla_training_agent.py # Autonomous training agent +├── openvla.Dockerfile # Training container (pinned deps) +└── slurm/ + └── finetune_openvla.sbatch # Slurm batch script +``` + +## Setup + +### 1. Clone this repository + +```bash +git clone https://github.com/awslabs/agentcore-samples.git +cd agentcore-samples/02-use-cases/openvla-agentcore-orchestrator +``` + +### 2. Install dependencies + +```bash +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +``` + +### 3. Configure environment + +```bash +cp .env.example .env +# Edit .env with your cluster details: +# CLUSTER_HOST, CLUSTER_USER, SSH_KEY_PATH, VLA_HOME +``` + +### 4. Prepare the training environment on the cluster + +Build the container and set up the workspace: + +```bash +# Build container image +docker build -t openvla-finetune -f openvla.Dockerfile . + +# Import as Enroot squashfs (on cluster or transfer after build) +enroot import -o /fsx/$USER/vla/openvla-finetune.sqsh dockerd://openvla-finetune:latest + +# Create workspace +ssh $CLUSTER_USER@$CLUSTER_HOST "mkdir -p /fsx/$USER/vla/{models,data,checkpoints,logs,scripts}" +``` + +### 5. Download model and data + +On the cluster: + +```bash +# Download OpenVLA-7B weights +python3 -c " +from huggingface_hub import snapshot_download +snapshot_download('openvla/openvla-7b-prismatic', local_dir='/fsx/$USER/vla/models/openvla-7b') +" + +# Download LIBERO dataset (RLDS format from Hugging Face Hub — not in stock TFDS catalog) +git lfs install +git clone https://huggingface.co/datasets/openvla/modified_libero_rlds /fsx/$USER/vla/data/libero_rlds +``` + +### 6. Copy the sbatch script to the cluster + +```bash +scp slurm/finetune_openvla.sbatch $CLUSTER_USER@$CLUSTER_HOST:/fsx/$USER/vla/scripts/ +``` + +## Usage + +### Option A: Run the MCP Server (for any MCP client) + +Start the MCP server as a long-running process. Any MCP-compatible client (Claude Desktop, Cursor, a custom agent, or AgentCore Runtime) can connect: + +```bash +python3 slurm_mcp_server.py +``` + +The server exposes 6 tools over stdio: +- `slurm_submit` — Submit a training job +- `slurm_status` — Check job state +- `slurm_logs` — Read stdout/stderr +- `slurm_cancel` — Cancel a job +- `slurm_info` — Cluster/partition info +- `slurm_metrics` — Parse training metrics from logs + +### Option B: Run the Autonomous Agent (full loop) + +The agent submits a job and monitors it through completion, handling anomalies: + +```bash +python3 vla_training_agent.py +``` + +This will: +1. Submit `finetune_openvla.sbatch` via sbatch +2. Poll every 30 seconds for status and metrics +3. Detect divergence/stall/NaN and recover (cancel + resubmit with adjusted LR) +4. Report final results + +Expected runtime: ~12–15 minutes for 500 steps on 1 node (8x H200). + +### Option C: Monitor an Existing Job + +If you already have a running/completed job: + +```bash +python3 vla_training_agent.py --monitor-job --check-interval 10 --max-checks 5 +``` + +## Configuration + +All settings are read from environment variables (or `.env` file): + +| Variable | Default | Description | +|----------|---------|-------------| +| `CLUSTER_HOST` | *(required)* | Cluster login node hostname | +| `CLUSTER_USER` | *(required)* | SSH username | +| `SSH_KEY_PATH` | `~/.ssh/id_rsa` | Path to SSH private key | +| `VLA_HOME` | `/fsx/$USER/vla` | Base workspace on FSx | +| `MAX_STEPS` | `500` | Training steps | +| `LEARNING_RATE` | `5e-4` | Initial learning rate | +| `BATCH_SIZE` | `16` | Per-device batch size | +| `LORA_RANK` | `32` | LoRA adapter rank | + +## Production Deployment with AgentCore + +In production, this same MCP toolset can be hosted on Amazon Bedrock AgentCore Runtime: + +- **AgentCore Gateway** exposes the Slurm MCP tools with authentication, rate limiting, and audit logging +- **AgentCore Memory** stores experiment history across sessions +- **AgentCore Policy** enforces cost guardrails (max GPU hours, budget caps) +- **AgentCore Observability** logs all agent decisions for review + +The local agent (`vla_training_agent.py`) demonstrates the orchestration logic; in production, an LLM model on Bedrock invokes the same tools through the Gateway. + +## Security Notes + +- SSH uses `StrictHostKeyChecking=no` for demo convenience. In production, pin host keys or use a jump host with managed credentials. +- Job IDs are validated (digits only) to prevent shell injection through the MCP tools. +- The `.env` file contains credentials — never commit it to version control. + +## References + +- [Amazon Bedrock AgentCore](https://docs.aws.amazon.com/bedrock/latest/userguide/agentcore.html) +- [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) +- [OpenVLA](https://github.com/openvla/openvla) — Vision-Language-Action model +- [LIBERO](https://libero-project.github.io/) — Robotic manipulation benchmark +- [SageMaker HyperPod](https://docs.aws.amazon.com/sagemaker/latest/dg/sagemaker-hyperpod.html) +- [SRE Agent (sibling sample)](../SRE-agent/) — Similar architecture for infrastructure operations diff --git a/02-use-cases/openvla-agentcore-orchestrator/openvla.Dockerfile b/02-use-cases/openvla-agentcore-orchestrator/openvla.Dockerfile new file mode 100644 index 000000000..9a5e139b0 --- /dev/null +++ b/02-use-cases/openvla-agentcore-orchestrator/openvla.Dockerfile @@ -0,0 +1,69 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: MIT-0 +# +# OpenVLA fine-tuning container for HyperPod Slurm (P5/P5en) +# Build: +# docker build -t openvla-finetune -f openvla.Dockerfile . +# Import for Pyxis/Enroot: +# enroot import -o /fsx/$USER/openvla-finetune.sqsh dockerd://openvla-finetune:latest + +# Base image: AWS HPC container with CUDA, EFA, NCCL, and aws-ofi-nccl pre-installed. +# Same base used by sibling test cases (nanoVLM). Provides: +# CUDA 12.8.1, NCCL 2.27.7, EFA 1.43.2 (libfabric), aws-ofi-nccl 1.16.3 +# Note: No published tag meets the CI floor (NCCL>=2.28, CUDA>=13.0) yet. +# PyTorch wheels bundle their own CUDA runtime (cu124), so training itself is unaffected. +FROM public.ecr.aws/hpc-cloud/nccl-tests:cuda12.8.1-efa1.43.2-ofiv1.16.3-ncclv2.27.7-1-testsv2.16.9 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + git \ + git-lfs \ + nvtop \ + && rm -rf /var/lib/apt/lists/* + +# --------------------------------------------------------------------------- +# Python training dependencies — pinned to the exact versions validated on +# HyperPod P5en (8x H200), job 5631, ~10 min for 500 LoRA steps. +# --------------------------------------------------------------------------- +RUN pip install --no-cache-dir \ + torch==2.6.0 \ + torchvision==0.21.0 \ + transformers==4.44.2 \ + peft==0.13.2 \ + accelerate==1.2.1 \ + "datasets>=2.14.0,<3.0.0" \ + tensorflow-datasets==4.9.3 \ + "tensorflow>=2.15.0,<2.18.0" \ + "tensorflow-graphics==2021.12.3" \ + "huggingface-hub>=0.20.0,<1.0.0" \ + "wandb>=0.16.0,<1.0.0" \ + "pillow>=10.0.0,<11.0.0" \ + "scipy>=1.11.0,<2.0.0" \ + "einops>=0.7.0,<1.0.0" \ + timm==0.9.10 \ + draccus==0.8.0 \ + sentencepiece==0.1.99 \ + tokenizers==0.19.1 + +# dlimp (data loading for RLDS) — no-deps to avoid pulling conflicting versions +RUN pip install --no-cache-dir --no-deps \ + git+https://github.com/kvablack/dlimp.git@5edaa4691567873d495633f2708982b42edf1972 + +# dlimp's fork used by OpenVLA for RLDS dataset loading +RUN pip install --no-cache-dir --no-deps \ + git+https://github.com/moojink/dlimp_openvla.git@040105d256bd28866cc6620621a3d5f7b6b91b46 + +# --------------------------------------------------------------------------- +# Clone OpenVLA and install in editable mode WITHOUT dependencies. +# This ensures our explicit pins above are not overwritten by OpenVLA's +# pyproject.toml (which hard-pins torch==2.2.0, transformers==4.40.1, etc.). +# --------------------------------------------------------------------------- +ARG OPENVLA_COMMIT=c8f03f48af692657d3060c19588038c7220e9af9 +RUN git clone https://github.com/openvla/openvla.git /openvla \ + && cd /openvla && git checkout ${OPENVLA_COMMIT} + +RUN cd /openvla && pip install --no-cache-dir --no-deps -e . + +# Symlink python for convenience +RUN ln -sf /usr/bin/python3 /usr/bin/python + +WORKDIR /openvla diff --git a/02-use-cases/openvla-agentcore-orchestrator/requirements.txt b/02-use-cases/openvla-agentcore-orchestrator/requirements.txt new file mode 100644 index 000000000..c9023384b --- /dev/null +++ b/02-use-cases/openvla-agentcore-orchestrator/requirements.txt @@ -0,0 +1,6 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: MIT-0 +# +# MCP server dependency (for slurm_mcp_server.py) +# The training agent (vla_training_agent.py) uses only stdlib. +mcp>=1.0.0,<2.0.0 diff --git a/02-use-cases/openvla-agentcore-orchestrator/slurm/finetune_openvla.sbatch b/02-use-cases/openvla-agentcore-orchestrator/slurm/finetune_openvla.sbatch new file mode 100644 index 000000000..f2e42329d --- /dev/null +++ b/02-use-cases/openvla-agentcore-orchestrator/slurm/finetune_openvla.sbatch @@ -0,0 +1,138 @@ +#!/bin/bash +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: MIT-0 +# +# OpenVLA LoRA Fine-Tuning on LIBERO (1 node, 8x GPUs) +# Tested on: HyperPod P5en (8x H200), ~10 min for 500 steps +# +# Prerequisites: +# 1. Build container: docker build -t openvla-finetune -f openvla.Dockerfile . +# 2. Import: enroot import -o $VLA_HOME/openvla-finetune.sqsh dockerd://openvla-finetune:latest +# 3. Download model + data (see README) +# +# Usage: +# export VLA_HOME=/fsx/$USER/vla +# cd $VLA_HOME # logs are written to $VLA_HOME/logs/ relative to cwd +# sbatch finetune_openvla.sbatch + +#SBATCH --job-name=openvla-finetune +#SBATCH --partition=p5en +#SBATCH --nodes=1 +#SBATCH --ntasks-per-node=1 +#SBATCH --gres=gpu:8 +#SBATCH --mem=0 +#SBATCH --time=01:55:00 +#SBATCH --exclusive +#SBATCH --output=logs/%j.out +#SBATCH --error=logs/%j.err + +set -euo pipefail + +# --------------------------------------------------------------------------- +# Configuration — set VLA_HOME or edit paths below +# --------------------------------------------------------------------------- +VLA_HOME="${VLA_HOME:-/fsx/$USER/vla}" + +MODEL_DIR="${VLA_HOME}/models/openvla-7b" +DATA_DIR="${VLA_HOME}/data/libero_rlds" +CKPT_DIR="${VLA_HOME}/checkpoints/run_${SLURM_JOB_ID}" +CONTAINER_IMAGE="${VLA_HOME}/openvla-finetune.sqsh" + +# Training hyperparameters (override via environment before sbatch) +DATASET_NAME="${DATASET_NAME:-libero_10_no_noops}" +MAX_STEPS="${MAX_STEPS:-500}" +SAVE_STEPS="${SAVE_STEPS:-500}" +LEARNING_RATE="${LEARNING_RATE:-5e-4}" +BATCH_SIZE="${BATCH_SIZE:-16}" +LORA_RANK="${LORA_RANK:-32}" +WANDB_MODE="${WANDB_MODE:-disabled}" + +GPUS_PER_NODE=8 + +# --------------------------------------------------------------------------- +# Environment (EFA + NCCL for multi-node readiness) +# --------------------------------------------------------------------------- +export NCCL_DEBUG=INFO +export FI_PROVIDER=efa +export FI_EFA_SET_CUDA_SYNC_MEMOPS=0 +export NCCL_SOCKET_IFNAME=^lo,docker0 +export TORCHDYNAMO_DISABLE=1 + +# --------------------------------------------------------------------------- +# Container mounts +# --------------------------------------------------------------------------- +declare -a SRUN_ARGS=( + --container-image "${CONTAINER_IMAGE}" + --container-mounts "/fsx:/fsx" +) + +# --------------------------------------------------------------------------- +# Torchrun args +# For multi-node, replace --standalone with: +# --nnodes=$SLURM_JOB_NUM_NODES --rdzv_id=$SLURM_JOB_ID +# --rdzv_backend=c10d --rdzv_endpoint=$SLURMD_NODENAME:29500 +# --------------------------------------------------------------------------- +declare -a TORCHRUN_ARGS=( + --standalone + --nnodes=1 + --nproc_per_node=${GPUS_PER_NODE} +) + +# --------------------------------------------------------------------------- +# Training args +# --------------------------------------------------------------------------- +declare -a TRAINING_ARGS=( + --vla_path "${MODEL_DIR}" + --data_root_dir "${DATA_DIR}" + --dataset_name "${DATASET_NAME}" + --run_root_dir "${CKPT_DIR}" + --adapter_tmp_dir "${CKPT_DIR}/adapter_tmp" + --lora_rank "${LORA_RANK}" + --batch_size "${BATCH_SIZE}" + --grad_accumulation_steps 1 + --learning_rate "${LEARNING_RATE}" + --max_steps "${MAX_STEPS}" + --save_steps "${SAVE_STEPS}" + --image_aug true + --wandb_project openvla-finetune +) + +# Only pass --wandb_entity if set (avoids passing empty string) +if [ -n "${WANDB_ENTITY:-}" ]; then + TRAINING_ARGS+=(--wandb_entity "${WANDB_ENTITY}") +fi + +# --------------------------------------------------------------------------- +# Create output dirs +# --------------------------------------------------------------------------- +mkdir -p "${CKPT_DIR}" "logs" + +echo "=========================================" +echo "Job ID: ${SLURM_JOB_ID}" +echo "Container: ${CONTAINER_IMAGE}" +echo "Model: ${MODEL_DIR}" +echo "Data: ${DATA_DIR}" +echo "Output: ${CKPT_DIR}" +echo "=========================================" + +# --------------------------------------------------------------------------- +# HyperPod auto-resume +# --------------------------------------------------------------------------- +declare -a AUTO_RESUME_ARGS=() +if [ -d "/opt/sagemaker_cluster" ]; then + echo "Detected HyperPod cluster — enabling auto-resume" + AUTO_RESUME_ARGS=("--auto-resume=1") +fi + +# --------------------------------------------------------------------------- +# Launch +# --------------------------------------------------------------------------- +export WANDB_MODE +srun "${AUTO_RESUME_ARGS[@]}" -l "${SRUN_ARGS[@]}" \ + torchrun "${TORCHRUN_ARGS[@]}" \ + /openvla/vla-scripts/finetune.py "${TRAINING_ARGS[@]}" + +echo "=========================================" +echo "Fine-tuning complete!" +echo "Checkpoints at: ${CKPT_DIR}" +echo "=========================================" diff --git a/02-use-cases/openvla-agentcore-orchestrator/slurm_mcp_server.py b/02-use-cases/openvla-agentcore-orchestrator/slurm_mcp_server.py new file mode 100644 index 000000000..d26bdd515 --- /dev/null +++ b/02-use-cases/openvla-agentcore-orchestrator/slurm_mcp_server.py @@ -0,0 +1,438 @@ +#!/usr/bin/env python3 +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: MIT-0 +""" +Slurm MCP Server — Exposes Slurm cluster operations as MCP tools. + +An LLM agent uses these tools to submit, monitor, and manage training jobs +on a HyperPod Slurm cluster via SSH. + +Tools: + - slurm_submit: Submit a batch job (sbatch) + - slurm_status: Get job status (squeue/sacct) + - slurm_logs: Read job stdout/stderr + - slurm_cancel: Cancel a running job (scancel) + - slurm_info: Get cluster/partition info (sinfo) + - slurm_metrics: Parse training metrics from log files + +Configuration: + Set environment variables (or use a .env file): + CLUSTER_HOST, CLUSTER_USER, SSH_KEY_PATH, VLA_HOME + +Requires: Python 3.10+ (mcp library dependency) +""" + +import asyncio +import json +import os +import re +from dataclasses import dataclass + +from mcp.server import Server +from mcp.server.stdio import stdio_server +from mcp.types import Tool, TextContent + +# ============================================================================= +# Configuration (from environment variables) +# ============================================================================= + + +def _load_dotenv(): + """Load .env file if present (minimal implementation, no dependency).""" + env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env") + if os.path.exists(env_path): + with open(env_path) as f: + for line in f: + line = line.strip() + if line and not line.startswith("#") and "=" in line: + key, _, value = line.partition("=") + os.environ.setdefault(key.strip(), value.strip()) + + +_load_dotenv() + + +@dataclass +class ClusterConfig: + host: str = os.environ.get("CLUSTER_HOST", "") + user: str = os.environ.get("CLUSTER_USER", "") + ssh_key: str = os.environ.get("SSH_KEY_PATH", "~/.ssh/id_rsa") + slurm_bin: str = os.environ.get("SLURM_BIN", "/opt/slurm/bin") + work_dir: str = os.environ.get("VLA_HOME", "") + + def validate(self): + missing = [] + if not self.host: + missing.append("CLUSTER_HOST") + if not self.user: + missing.append("CLUSTER_USER") + if not self.work_dir: + missing.append("VLA_HOME") + if missing: + raise EnvironmentError( + f"Missing required environment variables: {', '.join(missing)}. Set them in .env or export them." + ) + + +config = ClusterConfig() + + +# ============================================================================= +# Input Validation +# ============================================================================= + + +def _validate_job_id(job_id: str) -> str | None: + """Validate job_id is a numeric Slurm job ID. Returns error message or None.""" + if not re.fullmatch(r"\d+", str(job_id)): + return f"ERROR: job_id must be numeric, got: {job_id!r}" + return None + + +# ============================================================================= +# SSH Helper +# ============================================================================= + + +async def ssh_exec(cmd: str, timeout: int = 30) -> tuple[str, str, int]: + """Execute a command on the cluster via SSH. + + NOTE: StrictHostKeyChecking=no is used for convenience in dev/test. + For production, use known_hosts verification. + """ + ssh_key = os.path.expanduser(config.ssh_key) + ssh_cmd = [ + "ssh", + "-i", + ssh_key, + "-o", + "StrictHostKeyChecking=no", + "-o", + f"ConnectTimeout={timeout}", + f"{config.user}@{config.host}", + f"export PATH={config.slurm_bin}:$PATH && {cmd}", + ] + proc = await asyncio.create_subprocess_exec( + *ssh_cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout + 10) + return stdout.decode(), stderr.decode(), proc.returncode + + +# ============================================================================= +# MCP Server +# ============================================================================= + +app = Server("slurm-mcp") + + +@app.list_tools() +async def list_tools(): + return [ + Tool( + name="slurm_submit", + description="Submit a Slurm batch job. Returns the job ID.", + inputSchema={ + "type": "object", + "properties": { + "script_path": { + "type": "string", + "description": "Absolute path to the .sbatch script on the cluster", + }, + "overrides": { + "type": "object", + "description": 'Optional sbatch overrides (e.g. {"time": "01:00:00", "job-name": "test"})', + "additionalProperties": {"type": "string"}, + }, + }, + "required": ["script_path"], + }, + ), + Tool( + name="slurm_status", + description="Get status of jobs. Returns job ID, state, time, node, reason.", + inputSchema={ + "type": "object", + "properties": { + "job_id": { + "type": "string", + "description": "Specific job ID to check (optional — defaults to all user jobs)", + } + }, + }, + ), + Tool( + name="slurm_logs", + description="Read stdout/stderr from a training job. Returns last N lines.", + inputSchema={ + "type": "object", + "properties": { + "job_id": {"type": "string", "description": "The Slurm job ID"}, + "stream": { + "type": "string", + "enum": ["stdout", "stderr", "both"], + "description": "Which log stream to read (default: both)", + }, + "tail_lines": {"type": "integer", "description": "Number of lines from the end (default: 50)"}, + }, + "required": ["job_id"], + }, + ), + Tool( + name="slurm_cancel", + description="Cancel a running or pending Slurm job.", + inputSchema={ + "type": "object", + "properties": { + "job_id": {"type": "string", "description": "The Slurm job ID to cancel"}, + "reason": {"type": "string", "description": "Reason for cancellation (logged)"}, + }, + "required": ["job_id"], + }, + ), + Tool( + name="slurm_info", + description="Get cluster partition info — available nodes, GPUs, state.", + inputSchema={ + "type": "object", + "properties": { + "partition": {"type": "string", "description": "Partition name (optional — defaults to all)"} + }, + }, + ), + Tool( + name="slurm_metrics", + description="Parse training metrics from job logs. Extracts loss, learning rate, steps, GPU utilization.", + inputSchema={ + "type": "object", + "properties": {"job_id": {"type": "string", "description": "The Slurm job ID to parse metrics from"}}, + "required": ["job_id"], + }, + ), + ] + + +@app.call_tool() +async def call_tool(name: str, arguments: dict): + config.validate() + + if name == "slurm_submit": + return await _slurm_submit(arguments) + elif name == "slurm_status": + return await _slurm_status(arguments) + elif name == "slurm_logs": + return await _slurm_logs(arguments) + elif name == "slurm_cancel": + return await _slurm_cancel(arguments) + elif name == "slurm_info": + return await _slurm_info(arguments) + elif name == "slurm_metrics": + return await _slurm_metrics(arguments) + else: + return [TextContent(type="text", text=f"Unknown tool: {name}")] + + +# ============================================================================= +# Tool Implementations +# ============================================================================= + + +async def _slurm_submit(args: dict): + script = args["script_path"] + overrides = args.get("overrides", {}) + + override_flags = " ".join(f"--{k}={v}" for k, v in overrides.items()) + cmd = f"sbatch {override_flags} {script}".strip() + + stdout, stderr, rc = await ssh_exec(cmd) + if rc != 0: + return [TextContent(type="text", text=f"ERROR: sbatch failed\n{stderr}")] + + match = re.search(r"Submitted batch job (\d+)", stdout) + job_id = match.group(1) if match else "unknown" + + return [ + TextContent( + type="text", + text=json.dumps( + {"status": "submitted", "job_id": job_id, "script": script, "overrides": overrides}, indent=2 + ), + ) + ] + + +async def _slurm_status(args: dict): + job_id = args.get("job_id", "") + + if job_id: + err = _validate_job_id(job_id) + if err: + return [TextContent(type="text", text=err)] + cmd = f"squeue -j {job_id} --format='%i|%j|%T|%M|%N|%r' --noheader" + else: + cmd = f"squeue -u {config.user} --format='%i|%j|%T|%M|%N|%r' --noheader" + + stdout, stderr, rc = await ssh_exec(cmd) + + if not stdout.strip() or rc != 0: + if not job_id: + return [TextContent(type="text", text="No jobs found")] + # Job may have completed — check sacct + cmd2 = f"sacct -j {job_id} --format=JobID,JobName,State,Elapsed,ExitCode --noheader -P" + stdout2, _, _ = await ssh_exec(cmd2) + if stdout2.strip(): + return [TextContent(type="text", text=f"Job completed:\n{stdout2}")] + return [TextContent(type="text", text="No jobs found")] + + jobs = [] + for line in stdout.strip().split("\n"): + parts = line.split("|") + if len(parts) >= 6: + jobs.append( + { + "job_id": parts[0].strip(), + "name": parts[1].strip(), + "state": parts[2].strip(), + "time": parts[3].strip(), + "node": parts[4].strip(), + "reason": parts[5].strip(), + } + ) + + return [TextContent(type="text", text=json.dumps(jobs, indent=2))] + + +async def _slurm_logs(args: dict): + job_id = args["job_id"] + err = _validate_job_id(job_id) + if err: + return [TextContent(type="text", text=err)] + stream = args.get("stream", "both") + tail_lines = args.get("tail_lines", 50) + + log_dir = f"{config.work_dir}/logs" + result = {} + + if stream in ("stdout", "both"): + cmd = f"tail -n {tail_lines} {log_dir}/finetune_{job_id}.out 2>/dev/null || echo 'NO_FILE'" + stdout, _, _ = await ssh_exec(cmd) + result["stdout"] = stdout + + if stream in ("stderr", "both"): + cmd = f"tail -n {tail_lines} {log_dir}/finetune_{job_id}.err 2>/dev/null || echo 'NO_FILE'" + stdout, _, _ = await ssh_exec(cmd) + result["stderr"] = stdout + + return [TextContent(type="text", text=json.dumps(result, indent=2))] + + +async def _slurm_cancel(args: dict): + job_id = args["job_id"] + err = _validate_job_id(job_id) + if err: + return [TextContent(type="text", text=err)] + reason = args.get("reason", "cancelled by agent") + + cmd = f"scancel {job_id}" + stdout, stderr, rc = await ssh_exec(cmd) + + if rc != 0: + return [TextContent(type="text", text=f"ERROR: scancel failed\n{stderr}")] + + return [ + TextContent(type="text", text=json.dumps({"status": "cancelled", "job_id": job_id, "reason": reason}, indent=2)) + ] + + +async def _slurm_info(args: dict): + partition = args.get("partition", "") + + if partition: + cmd = f"sinfo -p {partition} --format='%P|%a|%D|%T|%G|%l' --noheader" + else: + cmd = "sinfo --format='%P|%a|%D|%T|%G|%l' --noheader" + + stdout, stderr, rc = await ssh_exec(cmd) + + # Also get GPU utilization + cmd2 = "squeue --format='%i|%u|%T|%b' --noheader" + stdout2, _, _ = await ssh_exec(cmd2) + + return [ + TextContent( + type="text", text=json.dumps({"partitions": stdout.strip(), "running_jobs": stdout2.strip()}, indent=2) + ) + ] + + +async def _slurm_metrics(args: dict): + job_id = args["job_id"] + err = _validate_job_id(job_id) + if err: + return [TextContent(type="text", text=err)] + log_file = f"{config.work_dir}/logs/finetune_{job_id}.out" + + cmd = f"""python3 -c " +import re, json + +metrics = {{'job_id': '{job_id}', 'steps': [], 'latest': {{}}}} + +try: + with open('{log_file}') as f: + lines = f.readlines() + + for line in lines: + # Match [METRICS] structured lines first + if '[METRICS]' in line: + step_m = re.search(r'step=(\\d+)', line) + loss_m = re.search(r'loss=([0-9.]+)', line) + if step_m and loss_m: + entry = {{'step': int(step_m.group(1)), 'loss': float(loss_m.group(1))}} + metrics['steps'].append(entry) + continue + + # Fallback: generic loss/step patterns + loss_match = re.search(r'loss[=:\\s]+([0-9.]+)', line, re.IGNORECASE) + step_match = re.search(r'step[=:\\s]+(\\d+)', line, re.IGNORECASE) + lr_match = re.search(r'lr[=:\\s]+([0-9.e-]+)', line, re.IGNORECASE) + + if loss_match and step_match: + entry = {{'step': int(step_match.group(1)), 'loss': float(loss_match.group(1))}} + if lr_match: + entry['lr'] = float(lr_match.group(1)) + metrics['steps'].append(entry) + + if metrics['steps']: + metrics['latest'] = metrics['steps'][-1] + metrics['total_steps_logged'] = len(metrics['steps']) + first = metrics['steps'][0]['loss'] + last = metrics['steps'][-1]['loss'] + metrics['loss_trend'] = 'decreasing' if last < first else 'increasing' if last > first else 'flat' + +except FileNotFoundError: + metrics['error'] = 'Log file not found (job may still be starting)' + +print(json.dumps(metrics, indent=2)) +" +""" + stdout, stderr, rc = await ssh_exec(cmd, timeout=15) + + if rc != 0: + return [TextContent(type="text", text=f"ERROR parsing metrics: {stderr}")] + + return [TextContent(type="text", text=stdout)] + + +# ============================================================================= +# Main +# ============================================================================= + + +async def main(): + config.validate() + async with stdio_server() as (read_stream, write_stream): + await app.run(read_stream, write_stream, app.create_initialization_options()) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/02-use-cases/openvla-agentcore-orchestrator/vla_training_agent.py b/02-use-cases/openvla-agentcore-orchestrator/vla_training_agent.py new file mode 100644 index 000000000..5fc6b68a7 --- /dev/null +++ b/02-use-cases/openvla-agentcore-orchestrator/vla_training_agent.py @@ -0,0 +1,509 @@ +#!/usr/bin/env python3 +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: MIT-0 +""" +VLA Training Agent — Autonomous agent that orchestrates VLA fine-tuning via Slurm. + +This agent demonstrates how Amazon Bedrock AgentCore can manage ML training: + 1. Submits a training job to a HyperPod Slurm cluster + 2. Monitors loss curve periodically + 3. Detects issues (divergence, stalling, NaN) + 4. Takes corrective action (adjust LR, restart with different params) + 5. Reports results + +In production, this would run on AgentCore Runtime with: + - Gateway providing the Slurm MCP tools + - Memory storing experiment history + - Policy enforcing cost guardrails + - Observability logging all decisions + +For local testing, it SSHs directly to the cluster. + +Configuration: + Set environment variables (or use a .env file): + CLUSTER_HOST, CLUSTER_USER, SSH_KEY_PATH, VLA_HOME, MAX_STEPS +""" + +import json +import math +import os +import re +import subprocess +import time +from dataclasses import dataclass, field +from typing import Optional + + +# ============================================================================= +# Configuration (from environment) +# ============================================================================= + + +def _load_dotenv(): + """Load .env file if present.""" + env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env") + if os.path.exists(env_path): + with open(env_path) as f: + for line in f: + line = line.strip() + if line and not line.startswith("#") and "=" in line: + key, _, value = line.partition("=") + os.environ.setdefault(key.strip(), value.strip()) + + +_load_dotenv() + + +def _get_env(key: str, default: str = "") -> str: + """Get environment variable with validation.""" + return os.environ.get(key, default) + + +# Cluster connection +CLUSTER_HOST = _get_env("CLUSTER_HOST") +CLUSTER_USER = _get_env("CLUSTER_USER") +SSH_KEY_PATH = os.path.expanduser(_get_env("SSH_KEY_PATH", "~/.ssh/id_rsa")) +VLA_HOME = _get_env("VLA_HOME") +MAX_STEPS = int(_get_env("MAX_STEPS", "500")) + + +@dataclass +class TrainingConfig: + """What the agent knows about the training setup.""" + + model_path: str = f"{VLA_HOME}/models/openvla-7b" + data_dir: str = f"{VLA_HOME}/data/libero_rlds" + dataset_name: str = _get_env("DATASET_NAME", "libero_10_no_noops") + script_path: str = f"{VLA_HOME}/scripts/finetune_openvla.sbatch" + max_steps: int = MAX_STEPS + + # Guardrails (would come from AgentCore Policy in production) + max_gpu_hours: float = 16.0 + max_retries: int = 3 + loss_divergence_threshold: float = 10.0 + loss_stall_patience: int = 5 + + +@dataclass +class ExperimentState: + """Agent's working memory (would be AgentCore Memory in production).""" + + current_job_id: Optional[str] = None + run_history: list = field(default_factory=list) + loss_history: list = field(default_factory=list) + decisions: list = field(default_factory=list) + retries: int = 0 + status: str = "idle" + + +# ============================================================================= +# Slurm Interface (SSH — in production, replaced by AgentCore Gateway + MCP) +# ============================================================================= + + +def _validate_config(): + """Ensure required config is set.""" + missing = [] + if not CLUSTER_HOST: + missing.append("CLUSTER_HOST") + if not CLUSTER_USER: + missing.append("CLUSTER_USER") + if not VLA_HOME: + missing.append("VLA_HOME") + if missing: + raise EnvironmentError( + f"Missing required environment variables: {', '.join(missing)}. " + f"Copy .env.example to .env and fill in your values." + ) + + +# NOTE: StrictHostKeyChecking=no is used for convenience in dev/test. +# For production, use known_hosts verification. +SSH_CMD_PREFIX = [ + "ssh", + "-i", + SSH_KEY_PATH, + "-o", + "StrictHostKeyChecking=no", + "-o", + "ConnectTimeout=10", + f"{CLUSTER_USER}@{CLUSTER_HOST}", +] + + +def ssh_exec(cmd: str) -> str: + """Execute command on cluster via SSH.""" + full_cmd = SSH_CMD_PREFIX + [f"export PATH=/opt/slurm/bin:$PATH && {cmd}"] + result = subprocess.run(full_cmd, capture_output=True, text=True, timeout=30) + return result.stdout + result.stderr + + +def submit_job(script_path: str, overrides: dict = None, env_vars: dict = None) -> str: + """Submit a Slurm job. Returns job ID. + + Args: + script_path: Path to the .sbatch script on the cluster. + overrides: sbatch option overrides (e.g. {"time": "01:00:00"}). + env_vars: Environment variables to export for the job (e.g. {"LEARNING_RATE": "1e-4"}). + """ + override_str = " ".join(f"--{k}={v}" for k, v in (overrides or {}).items()) + export_str = "" + if env_vars: + pairs = ",".join(f"{k}={v}" for k, v in env_vars.items()) + export_str = f"--export=ALL,{pairs}" + output = ssh_exec(f"sbatch {override_str} {export_str} {script_path}".strip()) + match = re.search(r"Submitted batch job (\d+)", output) + return match.group(1) if match else None + + +def get_job_status(job_id: str) -> dict: + """Get job state via squeue, falling back to sacct for completed jobs.""" + output = ssh_exec(f"squeue -j {job_id} --format='%T' --noheader") + state = output.strip() + if not state or "error" in state.lower() or "invalid" in state.lower(): + output = ssh_exec(f"sacct -j {job_id} --format=State --noheader -P") + state = output.strip().split("\n")[0] if output.strip() else "UNKNOWN" + return {"job_id": job_id, "state": state} + + +def get_training_metrics(job_id: str) -> dict: + """Parse training progress from log files. + + Sources (checked in priority order): + 1. [METRICS] lines in stdout — structured loss/acc/l1 per step + 2. tqdm progress bars in stderr — step counts and throughput + 3. Checkpoint existence — final artifact verification + """ + metrics = {"job_id": job_id, "steps": [], "losses": []} + max_steps = MAX_STEPS + + out_file = f"{VLA_HOME}/logs/finetune_{job_id}.out" + err_file = f"{VLA_HOME}/logs/finetune_{job_id}.err" + + # --- Source 1: Structured [METRICS] lines from stdout --- + cmd_metrics = f"""grep '\\[METRICS\\]' {out_file} 2>/dev/null""" + metrics_output = ssh_exec(cmd_metrics) + + for line in metrics_output.strip().split("\n"): + if "[METRICS]" not in line: + continue + step_m = re.search(r"step=(\d+)", line) + loss_m = re.search(r"loss=([0-9.]+)", line) + acc_m = re.search(r"acc=([0-9.]+)", line) + l1_m = re.search(r"l1=([0-9.]+)", line) + if step_m and loss_m: + entry = {"step": int(step_m.group(1)), "loss": float(loss_m.group(1))} + if acc_m: + entry["acc"] = float(acc_m.group(1)) + if l1_m: + entry["l1"] = float(l1_m.group(1)) + metrics["steps"].append(entry) + metrics["losses"].append(entry["loss"]) + + # --- Source 2: tqdm progress from stderr (fallback) --- + if not metrics["steps"]: + cmd = f"""grep -oP '\\d+/{max_steps} \\[\\d+:\\d+' {err_file} 2>/dev/null | sort -t/ -k1 -n -u | tail -20""" + output = ssh_exec(cmd) + for line in output.strip().split("\n"): + step_match = re.search(rf"(\d+)/{max_steps}", line) + if step_match: + step = int(step_match.group(1)) + metrics["steps"].append({"step": step}) + + # Throughput from last tqdm line + cmd2 = f"""grep -oP '[0-9.]+it/s' {err_file} 2>/dev/null | tail -1""" + throughput = ssh_exec(cmd2).strip() + if throughput: + metrics["throughput"] = throughput + + # --- Source 3: Status messages and checkpoint --- + cmd3 = f"""grep -E 'Saving|Max step|Fine-tuning complete|trainable params' {out_file} 2>/dev/null | sort -u | tail -5""" + status_output = ssh_exec(cmd3) + if status_output.strip(): + metrics["status_messages"] = [line.strip()[:80] for line in status_output.strip().split("\n") if line.strip()] + + ckpt_dir = f"{VLA_HOME}/checkpoints/run_{job_id}" + cmd4 = f"""ls -lh {ckpt_dir}/*/*.safetensors 2>/dev/null | wc -l; du -sh {ckpt_dir} 2>/dev/null""" + ckpt_output = ssh_exec(cmd4) + parts = ckpt_output.strip().split("\n") + if parts: + metrics["checkpoint_shards"] = parts[0].strip() + if len(parts) > 1: + metrics["checkpoint_size"] = parts[1].strip().split()[0] if parts[1].strip() else "unknown" + + # --- Summary --- + if metrics["steps"]: + metrics["latest_step"] = metrics["steps"][-1]["step"] + metrics["total_steps"] = max_steps + metrics["progress_pct"] = f"{metrics['latest_step'] / max_steps * 100:.0f}%" + + if metrics["losses"]: + metrics["latest_loss"] = metrics["losses"][-1] + metrics["min_loss"] = min(metrics["losses"]) + metrics["max_loss"] = max(metrics["losses"]) + + return metrics + + +def cancel_job(job_id: str) -> bool: + """Cancel a job.""" + output = ssh_exec(f"scancel {job_id}") + return "error" not in output.lower() + + +# ============================================================================= +# Agent Logic +# ============================================================================= + + +class VLATrainingAgent: + """ + Agent that manages VLA training autonomously. + + Decision loop: + 1. Submit job if none running + 2. Monitor metrics every N seconds + 3. Detect anomalies (divergence, NaN, stall) + 4. Take corrective action + 5. Report final results + """ + + def __init__(self, config: TrainingConfig = None): + self.config = config or TrainingConfig() + self.state = ExperimentState() + self.check_interval = 30 + + def log_decision(self, action: str, reason: str, details: dict = None): + """Record agent decisions (AgentCore Observability in production).""" + entry = { + "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"), + "action": action, + "reason": reason, + "details": details or {}, + } + self.state.decisions.append(entry) + print(f"[AGENT] {action}: {reason}") + + def submit_training(self, overrides: dict = None, env_vars: dict = None) -> str: + """Submit a training job.""" + self.log_decision("SUBMIT", "Starting training run", {"overrides": overrides, "env_vars": env_vars}) + job_id = submit_job(self.config.script_path, overrides, env_vars) + + if job_id: + self.state.current_job_id = job_id + self.state.status = "training" + self.state.run_history.append({"job_id": job_id, "overrides": overrides}) + self.log_decision("SUBMITTED", f"Job {job_id} queued") + else: + self.log_decision("ERROR", "Failed to submit job") + self.state.status = "failed" + + return job_id + + def check_health(self) -> str: + """Check training health. Returns: healthy, diverged, stalled, failed, complete.""" + if not self.state.current_job_id: + return "no_job" + + status = get_job_status(self.state.current_job_id) + job_state = status["state"] + + if "COMPLETED" in job_state: + self.state.status = "complete" + return "complete" + + if "FAILED" in job_state or "CANCELLED" in job_state: + return "failed" + + if "PENDING" in job_state: + return "pending" + + if "RUNNING" not in job_state: + return "unknown" + + # Parse metrics for anomaly detection + metrics = get_training_metrics(self.state.current_job_id) + + if not metrics.get("steps"): + return "starting" + + latest_loss = metrics.get("latest_loss") + if latest_loss is not None: + self.state.loss_history.append(latest_loss) + + if latest_loss > self.config.loss_divergence_threshold: + self.log_decision("DETECT", f"Loss diverged: {latest_loss:.4f}", metrics) + return "diverged" + + if math.isnan(latest_loss): + self.log_decision("DETECT", "NaN loss detected", metrics) + return "diverged" + + if len(self.state.loss_history) >= self.config.loss_stall_patience: + recent = self.state.loss_history[-self.config.loss_stall_patience :] + if all(abs(recent[i] - recent[i - 1]) < 0.001 for i in range(1, len(recent))): + self.log_decision("DETECT", f"Loss stalled at {latest_loss:.4f}", metrics) + return "stalled" + + return "healthy" + + def recover(self, issue: str): + """Take corrective action based on the issue detected.""" + self.state.retries += 1 + + if self.state.retries > self.config.max_retries: + self.log_decision("ABORT", f"Max retries ({self.config.max_retries}) exceeded") + self.state.status = "failed" + return + + if self.state.current_job_id: + cancel_job(self.state.current_job_id) + self.log_decision("CANCEL", f"Cancelled job {self.state.current_job_id}") + + if issue == "diverged": + current_lr = 5e-4 / (5**self.state.retries) + self.log_decision("RECOVER", f"Reducing LR to {current_lr:.2e}") + self.submit_training( + overrides={"comment": f"retry_{self.state.retries}_lr_{current_lr}"}, + env_vars={"LEARNING_RATE": f"{current_lr:.2e}"}, + ) + + elif issue == "stalled": + current_lr = 5e-4 / (2**self.state.retries) + self.log_decision("RECOVER", f"Loss stalled — restarting with LR {current_lr:.2e}") + self.submit_training( + overrides={"comment": f"retry_{self.state.retries}_stall_fix"}, + env_vars={"LEARNING_RATE": f"{current_lr:.2e}"}, + ) + + elif issue == "failed": + self.log_decision("RECOVER", "Job failed — retrying") + self.submit_training({"comment": f"retry_{self.state.retries}_after_failure"}) + + def run(self, max_checks: int = 100): + """Main agent loop.""" + print("=" * 60) + print("VLA Training Agent — Starting") + print(f"Model: {self.config.model_path}") + print(f"Dataset: {self.config.dataset_name}") + print("=" * 60) + + self.submit_training() + + if self.state.status == "failed": + return self.report() + + for check_num in range(max_checks): + time.sleep(self.check_interval) + + health = self.check_health() + print(f"[CHECK {check_num + 1}] Health: {health} | Job: {self.state.current_job_id}") + + if health == "complete": + self.log_decision("COMPLETE", "Training finished successfully") + break + + elif health in ("diverged", "stalled", "failed"): + self.recover(health) + if self.state.status == "failed": + break + + elif health == "pending": + print(" Job still pending...") + + elif health == "starting": + print(" Job initializing (no metrics yet)...") + + return self.report() + + def report(self) -> dict: + """Generate final experiment report.""" + report = { + "status": self.state.status, + "total_runs": len(self.state.run_history), + "total_retries": self.state.retries, + "decisions": self.state.decisions, + "final_loss": self.state.loss_history[-1] if self.state.loss_history else None, + "loss_history_summary": { + "first": self.state.loss_history[0] if self.state.loss_history else None, + "last": self.state.loss_history[-1] if self.state.loss_history else None, + "min": min(self.state.loss_history) if self.state.loss_history else None, + "count": len(self.state.loss_history), + }, + } + + print("\n" + "=" * 60) + print("EXPERIMENT REPORT") + print("=" * 60) + print(json.dumps(report, indent=2, default=str)) + + return report + + +# ============================================================================= +# Entry point +# ============================================================================= + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser(description="VLA Training Agent") + parser.add_argument("--check-interval", type=int, default=30, help="Seconds between health checks") + parser.add_argument("--max-checks", type=int, default=100, help="Maximum monitoring iterations") + parser.add_argument("--submit-only", action="store_true", help="Just submit, don't monitor") + parser.add_argument("--monitor-job", type=str, help="Monitor existing job ID instead of submitting") + args = parser.parse_args() + + _validate_config() + + agent = VLATrainingAgent() + agent.check_interval = args.check_interval + + if args.monitor_job: + agent.state.current_job_id = args.monitor_job + agent.state.status = "training" + print("=" * 60) + print(f"VLA Training Agent — Monitoring Job {args.monitor_job}") + print("=" * 60) + for i in range(args.max_checks): + time.sleep(agent.check_interval) + health = agent.check_health() + print(f"[CHECK {i + 1}] Health: {health} | Job: {agent.state.current_job_id}") + if health in ("complete", "failed"): + break + elif health in ("diverged", "stalled"): + agent.recover(health) + if agent.state.status == "failed": + break + # Final metrics + metrics = get_training_metrics(agent.state.current_job_id) + if metrics.get("steps"): + summary_parts = [f"Parsed {len(metrics['steps'])} unique steps"] + if metrics.get("throughput"): + summary_parts.append(f"Throughput: {metrics['throughput']}") + if metrics.get("checkpoint_size"): + summary_parts.append(f"Checkpoint: {metrics['checkpoint_size']}") + if metrics.get("progress_pct"): + summary_parts.append(f"Progress: {metrics['progress_pct']}") + agent.log_decision("METRICS", " | ".join(summary_parts)) + if metrics.get("losses"): + agent.log_decision( + "LOSS_CURVE", + f"Steps: {len(metrics['losses'])} | " + f"First: {metrics['losses'][0]:.4f} | " + f"Last: {metrics['losses'][-1]:.4f} | " + f"Min: {min(metrics['losses']):.4f} | " + f"Max: {max(metrics['losses']):.4f}", + ) + last_5 = metrics["losses"][-5:] + trend = " -> ".join(f"{val:.4f}" for val in last_5) + agent.log_decision("LOSS_TREND", trend) + if metrics.get("status_messages"): + for msg in metrics["status_messages"][-3:]: + agent.log_decision("STATUS", msg) + agent.report() + elif args.submit_only: + agent.submit_training() + print(f"Job submitted: {agent.state.current_job_id}") + else: + agent.run(max_checks=args.max_checks)