Skip to content

Commit fbf3c70

Browse files
Merge pull request #53 from AI-Hypercomputer/shangkun-upgrade-servers
feat/refactor: Support remote TPU VM and GKE cluster as backend.
2 parents 61b420a + e101b58 commit fbf3c70

11 files changed

Lines changed: 598 additions & 424 deletions

File tree

MaxKernel/.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ session_info/
44
*egg-info
55
*.txt
66
.adk
7-
eval_config.yaml
7+
eval_config.yaml
8+
gke_config.yaml

MaxKernel/auto_agent/custom_types.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ def api_client(self) -> Client:
2121
"headers": self._tracking_headers(),
2222
"retry_options": self.retry_options,
2323
"base_url": base_url,
24-
"timeout": 240000, # 240 seconds in milliseconds
24+
"timeout": 600000, # 600 seconds in milliseconds
2525
}
2626
if api_version:
2727
kwargs_for_http_options["api_version"] = api_version

MaxKernel/auto_agent/server_utils/cpu_server.py

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@
22
import json
33
import logging
44
import os
5+
import socket
56
import sys
67
import tempfile
78
from typing import Optional
89

10+
import uvicorn
11+
import yaml
912
from fastapi import FastAPI, HTTPException
1013
from pydantic import BaseModel
1114

@@ -503,7 +506,52 @@ async def get_backend_version() -> str:
503506
return GetBackendVersionResponse(backend_version="CPU")
504507

505508

509+
def _get_local_ip():
510+
try:
511+
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
512+
s.connect(("8.8.8.8", 80))
513+
local_ip = s.getsockname()[0]
514+
s.close()
515+
return local_ip
516+
except Exception:
517+
return "127.0.0.1"
518+
519+
520+
def get_local_cpu_port(cfg_path: str = "eval_config.yaml") -> Optional[int]:
521+
"""Checks eval_config.yaml and returns the port if a local CPU server is needed."""
522+
try:
523+
with open(cfg_path, "r") as file:
524+
config = yaml.safe_load(file)
525+
except FileNotFoundError:
526+
logging.error(f"Config file {cfg_path} not found.")
527+
return None
528+
529+
backends = config.get("backends", [])
530+
local_ip = _get_local_ip()
531+
532+
# Find all backends that are local CPUs
533+
local_cpu_backends = [
534+
b
535+
for b in backends
536+
if b.get("type") == "cpu"
537+
and b.get("ip") in ["127.0.0.1", "localhost", local_ip]
538+
]
539+
540+
if not local_cpu_backends:
541+
return None
542+
543+
return local_cpu_backends[0].get("port", CPU_SERVER_PORT)
544+
545+
506546
if __name__ == "__main__":
507-
import uvicorn
547+
cpu_port = get_local_cpu_port()
548+
549+
if cpu_port is None:
550+
logging.info(
551+
"No local CPU server needed according to eval_config.yaml. Skipping"
552+
" startup."
553+
)
554+
sys.exit(0)
508555

509-
uvicorn.run(app, host="0.0.0.0", port=CPU_SERVER_PORT)
556+
logging.info(f"Starting CPU server on port {cpu_port}")
557+
uvicorn.run(app, host="0.0.0.0", port=cpu_port)

MaxKernel/auto_agent/server_utils/eval_server.py

Lines changed: 131 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,73 @@
11
import asyncio
2+
import atexit
23
import logging
4+
import subprocess
35
import time
46
import uuid
7+
from contextlib import asynccontextmanager
58
from enum import Enum
69
from typing import Optional
710

811
import aiohttp
12+
import requests
913
import yaml
1014
from fastapi import BackgroundTasks, FastAPI, HTTPException
1115
from pydantic import BaseModel
1216

1317
from auto_agent.constants import EVAL_SERVER_PORT
14-
from auto_agent.server_utils.tpu_server import get_tpu_version
1518

1619
logging.basicConfig(
1720
level=logging.INFO,
1821
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
1922
datefmt="%Y-%m-%d %H:%M:%S",
2023
)
2124

22-
app = FastAPI(title="Agent Evaluation Server", version="1.0.0")
25+
26+
@asynccontextmanager
27+
async def lifespan(app: FastAPI):
28+
# Start background task to clean up old completed tasks (TTL: 30 mins / 1800 seconds)
29+
cleanup_task = asyncio.create_task(
30+
clean_old_tasks_periodically(ttl_seconds=1800, check_interval_seconds=60)
31+
)
32+
yield
33+
# Clean up background task on shutdown
34+
cleanup_task.cancel()
35+
try:
36+
await cleanup_task
37+
except asyncio.CancelledError:
38+
pass
39+
logging.info("Shutting down eval_server, cleaning up tunnels...")
40+
evaluator._cleanup_tunnels()
41+
42+
43+
async def clean_old_tasks_periodically(
44+
ttl_seconds: int = 1800, check_interval_seconds: int = 60
45+
):
46+
"""Periodically removes completed tasks older than ttl_seconds."""
47+
while True:
48+
try:
49+
await asyncio.sleep(check_interval_seconds)
50+
now = time.time()
51+
to_remove = []
52+
for task_id, task_info in list(_tasks.items()):
53+
# Only clean up tasks that have finished (are not queued or running)
54+
if task_info["status"] not in [TaskStatus.QUEUED, TaskStatus.RUNNING]:
55+
completed_at = task_info.get("completed_at")
56+
if completed_at and (now - completed_at) > ttl_seconds:
57+
to_remove.append(task_id)
58+
59+
for task_id in to_remove:
60+
_tasks.pop(task_id, None)
61+
logging.info(f"Cleaned up expired task {task_id} from memory.")
62+
except asyncio.CancelledError:
63+
break
64+
except Exception as e:
65+
logging.error(f"Error in task cleanup loop: {e}")
66+
67+
68+
app = FastAPI(
69+
title="Agent Evaluation Server", version="1.0.0", lifespan=lifespan
70+
)
2371

2472
backend_semaphore = asyncio.Semaphore(1)
2573

@@ -34,7 +82,20 @@ def __init__(self, name: str, ip: str, port: int, backend_type: str = "tpu"):
3482

3583
# Get version info for display purposes
3684
if backend_type == "tpu":
37-
self.version = get_tpu_version()
85+
url = f"http://{self.ip}:{self.port}/get_tpu_version"
86+
try:
87+
resp = requests.post(url, timeout=10)
88+
if resp.status_code == 200:
89+
data = resp.json()
90+
if isinstance(data, dict):
91+
self.version = data.get("tpu_version", "TPU version not found")
92+
else:
93+
self.version = str(data)
94+
else:
95+
self.version = f"HTTP Error {resp.status_code}"
96+
except Exception as e:
97+
logging.warning(f"Failed to get TPU version from {url}: {e}")
98+
self.version = "Unknown TPU"
3899
else:
39100
self.version = "CPU"
40101

@@ -100,22 +161,86 @@ def __init__(self, cfg_path="eval_config.yaml"):
100161

101162
self.backends = []
102163

164+
self.tunnels = []
165+
self._load_backends()
166+
167+
logging.info(f"Evaluator initialized with backends: {self.backends}")
168+
169+
def _load_backends(self):
103170
if "backends" in self.config and self.config["backends"]:
104171
logging.info("Using 'backends' configuration format")
105172
for backend_config in self.config["backends"]:
173+
# Check if gcloud TPU VM tunnel is requested
174+
self._create_tunnel(backend_config)
175+
106176
backend_obj = Backend(
107177
name=backend_config["name"],
108178
ip=backend_config["ip"],
109179
port=backend_config["port"],
110180
backend_type=backend_config.get("type", "tpu"),
111181
)
112182
self.backends.append(backend_obj)
183+
184+
# Register cleanup
185+
if self.tunnels:
186+
logging.info(f"Registering cleanup for {len(self.tunnels)} tunnels.")
187+
atexit.register(self._cleanup_tunnels)
188+
113189
else:
114190
raise ValueError(
115191
"No backends configured in eval_config.yaml. Please use the 'backends' format."
116192
)
117193

118-
logging.info(f"Evaluator initialized with backends: {self.backends}")
194+
def _create_tunnel(self, backend_config):
195+
if "tpu_vm" in backend_config:
196+
local_port = backend_config["port"]
197+
gt = backend_config["tpu_vm"]
198+
tpu_name = gt["tpu_name"]
199+
zone = gt["zone"]
200+
project = gt["project"]
201+
remote_port = gt["port"]
202+
203+
logging.info(f"Constructing gcloud SSH tunnel to {tpu_name}...")
204+
205+
cmd = [
206+
"gcloud",
207+
"compute",
208+
"tpus",
209+
"tpu-vm",
210+
"ssh",
211+
tpu_name,
212+
f"--zone={zone}",
213+
f"--project={project}",
214+
"--",
215+
"-N",
216+
"-L",
217+
f"{local_port}:localhost:{remote_port}",
218+
]
219+
220+
try:
221+
process = subprocess.Popen(cmd)
222+
self.tunnels.append(process)
223+
logging.info(
224+
f"Gcloud SSH tunnel started for {backend_config['name']} (PID: {process.pid})"
225+
)
226+
time.sleep(10) # Blocking sleep as requested
227+
except Exception as e:
228+
logging.error(f"Failed to start gcloud SSH tunnel: {e}")
229+
230+
def _cleanup_tunnels(self):
231+
if self.tunnels:
232+
logging.info("Cleaning up SSH tunnels...")
233+
for p in self.tunnels:
234+
try:
235+
p.terminate()
236+
p.wait(timeout=5)
237+
logging.info(f"Tunnel PID {p.pid} terminated.")
238+
except Exception as e:
239+
logging.error(f"Failed to terminate tunnel PID {p.pid}: {e}")
240+
try:
241+
p.kill()
242+
except:
243+
pass
119244

120245
def get_available_backend(self, backend_type: Optional[str] = None):
121246
"""
@@ -173,6 +298,8 @@ async def run_evaluation_task(task_id: str, request: EvalRequest):
173298
except Exception as e:
174299
_tasks[task_id]["status"] = TaskStatus.FAILED
175300
_tasks[task_id]["error"] = str(e)
301+
finally:
302+
_tasks[task_id]["completed_at"] = time.time()
176303

177304

178305
async def _perform_evaluation(request: EvalRequest):

0 commit comments

Comments
 (0)