353 lines
11 KiB
Python
353 lines
11 KiB
Python
"""Common process management and launcher utilities for botWebWars agents."""
|
|
|
|
import argparse
|
|
import os
|
|
import re
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
from typing import List, Optional, Tuple
|
|
|
|
# ANSI color codes for pretty terminal logging
|
|
RESET = "\033[0m"
|
|
BOLD = "\033[1m"
|
|
GREEN = "\033[1;32m"
|
|
YELLOW = "\033[1;33m"
|
|
RED = "\033[1;31m"
|
|
CYAN = "\033[1;36m"
|
|
MAGENTA = "\033[1;35m"
|
|
BLUE = "\033[1;34m"
|
|
|
|
CONSOLE_COLORS = [
|
|
"\033[1;34m", # Blue
|
|
"\033[1;35m", # Magenta
|
|
"\033[1;36m", # Cyan
|
|
"\033[1;32m", # Green
|
|
"\033[1;33m", # Yellow
|
|
"\033[1;31m", # Red
|
|
"\033[1;94m", # Light Blue
|
|
"\033[1;95m", # Light Magenta
|
|
"\033[1;96m", # Light Cyan
|
|
"\033[1;92m", # Light Green
|
|
]
|
|
|
|
DEFAULT_BOT_COLORS = [
|
|
"#3b82f6", # Blue
|
|
"#8b5cf6", # Purple
|
|
"#ec4899", # Pink
|
|
"#06b6d4", # Cyan
|
|
"#f59e0b", # Amber
|
|
"#10b981", # Emerald
|
|
"#6366f1", # Indigo
|
|
"#f43f5e", # Rose
|
|
"#14b8a6", # Teal
|
|
"#e11d48", # Crimson
|
|
]
|
|
|
|
DEFAULT_TROLL_COLORS = [
|
|
"#15803d", # Forest Green
|
|
"#166534", # Dark Green
|
|
"#047857", # Emerald / Swamp
|
|
"#4d7c0f", # Olive Lime
|
|
"#3f6212", # Deep Olive
|
|
"#b45309", # Dark Amber / Brown
|
|
"#7c2d12", # Rust / Bloodwood
|
|
"#581c87", # Dark Purple
|
|
"#0f766e", # Deep Teal
|
|
"#1e293b", # Slate Dark
|
|
]
|
|
|
|
|
|
def sanitize_model_name(model: str) -> str:
|
|
"""Sanitizes model name by replacing non-alphanumeric characters with underscores.
|
|
|
|
Example: 'gemma4:e4b' -> 'gemma4_e4b', 'llama3.2:1b' -> 'llama3_2_1b'
|
|
"""
|
|
cleaned = re.sub(r"[^a-zA-Z0-9]+", "_", model).strip("_")
|
|
return cleaned or "model"
|
|
|
|
|
|
def make_agent_name(folder: str, model: str, index: int, prefix: Optional[str] = None) -> str:
|
|
"""Generates the standardized agent name in the format: {folder}_{model}_{index}.
|
|
|
|
Example: 'botagent_ai_gemma4_e4b_3'
|
|
"""
|
|
base_prefix = prefix.strip("_") if prefix else folder
|
|
clean_model = sanitize_model_name(model)
|
|
return f"{base_prefix}_{clean_model}_{index}"
|
|
|
|
|
|
def find_python_executable(agent_dir: Path, custom_python: Optional[str] = None) -> str:
|
|
"""Locates the appropriate Python binary:
|
|
1. Custom explicit python path if provided.
|
|
2. Active virtual environment if invoked inside one.
|
|
3. Agent directory local virtual environment (e.g. {agent_dir}/venv/bin/python).
|
|
4. sys.executable or system python3.
|
|
"""
|
|
if custom_python:
|
|
return custom_python
|
|
|
|
# If the user explicitly activated an environment outside
|
|
if getattr(sys, "base_prefix", None) != sys.prefix:
|
|
return sys.executable
|
|
|
|
# Check local venv inside the agent's folder
|
|
local_venv = agent_dir / "venv" / "bin" / "python"
|
|
if local_venv.is_file() and os.access(local_venv, os.X_OK):
|
|
return str(local_venv)
|
|
|
|
# Fallback to current sys.executable
|
|
return sys.executable
|
|
|
|
|
|
def stream_output(
|
|
agent_name: str,
|
|
color_code: str,
|
|
proc: subprocess.Popen,
|
|
log_file: Optional[Path],
|
|
) -> None:
|
|
"""Streams child process stdout/stderr line-by-line to console and log file."""
|
|
log_fp = None
|
|
if log_file:
|
|
try:
|
|
log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
log_fp = open(log_file, "a", encoding="utf-8")
|
|
except Exception as e:
|
|
print(f"{RED}⚠️ Failed to open log file {log_file}: {e}{RESET}", flush=True)
|
|
|
|
last_lobby_time = 0.0
|
|
try:
|
|
if proc.stdout:
|
|
for raw_line in iter(proc.stdout.readline, ""):
|
|
if not raw_line:
|
|
break
|
|
if log_fp:
|
|
log_fp.write(raw_line)
|
|
log_fp.flush()
|
|
|
|
line = raw_line.rstrip("\r\n")
|
|
if not line:
|
|
continue
|
|
|
|
# Throttle lobby polling notifications to avoid console flooding
|
|
if "[LOBBY]" in line:
|
|
now = time.time()
|
|
if now - last_lobby_time < 8.0:
|
|
continue
|
|
last_lobby_time = now
|
|
|
|
print(f"{color_code}[{agent_name}]{RESET} {line}", flush=True)
|
|
except Exception:
|
|
pass
|
|
finally:
|
|
if log_fp:
|
|
try:
|
|
log_fp.close()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def stop_all_processes(procs: List[Tuple[str, subprocess.Popen]]) -> None:
|
|
"""Gracefully terminates all child processes by sending SIGINT first."""
|
|
if not procs:
|
|
return
|
|
|
|
running_procs = [(name, p) for name, p in procs if p.poll() is None]
|
|
if not running_procs:
|
|
return
|
|
|
|
print(f"\n{YELLOW}🛑 Stopping {len(running_procs)} agent session(s) gracefully...{RESET}", flush=True)
|
|
|
|
# 1. Send SIGINT so bot.py catches KeyboardInterrupt and cleanly deregisters from the board
|
|
for name, proc in running_procs:
|
|
if proc.poll() is None:
|
|
try:
|
|
proc.send_signal(signal.SIGINT)
|
|
except (ProcessLookupError, OSError):
|
|
pass
|
|
|
|
# 2. Give processes up to 4 seconds to deregister and exit cleanly
|
|
deadline = time.time() + 4.0
|
|
for name, proc in running_procs:
|
|
remaining = max(0.1, deadline - time.time())
|
|
try:
|
|
proc.wait(timeout=remaining)
|
|
except subprocess.TimeoutExpired:
|
|
pass
|
|
|
|
# 3. Terminate/kill any processes that hung
|
|
for name, proc in running_procs:
|
|
if proc.poll() is None:
|
|
print(f"{RED}⚠️ Agent {name} did not exit in time; terminating...{RESET}", flush=True)
|
|
try:
|
|
proc.terminate()
|
|
proc.wait(timeout=1.0)
|
|
except Exception:
|
|
try:
|
|
proc.kill()
|
|
except Exception:
|
|
pass
|
|
|
|
print(f"{GREEN}✅ All agent sessions stopped cleanly.{RESET}\n", flush=True)
|
|
|
|
|
|
def run_agent_launcher(
|
|
agent_type: str,
|
|
folder: str,
|
|
palette: List[str],
|
|
args: argparse.Namespace,
|
|
extra_args_builder=None,
|
|
) -> None:
|
|
"""Generic orchestrator for launching bot and troll agent pools."""
|
|
root_dir = Path(__file__).resolve().parent
|
|
agent_dir = root_dir / folder
|
|
|
|
if not agent_dir.is_dir():
|
|
print(f"{RED}❌ Error: Agent directory '{agent_dir}' not found.{RESET}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
entry_file = agent_dir / "bot.py"
|
|
if not entry_file.is_file():
|
|
print(f"{RED}❌ Error: Entrypoint '{entry_file}' not found.{RESET}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
python_bin = find_python_executable(agent_dir, args.python)
|
|
count = max(1, args.count)
|
|
start_index = max(1, args.start_index)
|
|
prefix = getattr(args, "prefix", None)
|
|
|
|
# Prepare list of commands and configurations
|
|
agent_configs = []
|
|
for offset in range(count):
|
|
idx = start_index + offset
|
|
name = make_agent_name(folder=folder, model=args.model, index=idx, prefix=prefix)
|
|
color = args.color if args.color else palette[offset % len(palette)]
|
|
console_color = CONSOLE_COLORS[offset % len(CONSOLE_COLORS)]
|
|
|
|
cmd = [
|
|
python_bin,
|
|
"bot.py",
|
|
"--name", name,
|
|
"--color", color,
|
|
"-s", str(args.strength),
|
|
"-H", str(args.health),
|
|
"--ollama-url", args.ollama_url,
|
|
"--ollama-model", args.model,
|
|
"-u", args.server_url,
|
|
]
|
|
|
|
if extra_args_builder:
|
|
extra = extra_args_builder(args)
|
|
if extra:
|
|
cmd.extend(extra)
|
|
|
|
log_file = None
|
|
if not args.no_logs and args.log_dir:
|
|
log_file = Path(args.log_dir).resolve() / f"{name}.log"
|
|
|
|
agent_configs.append({
|
|
"index": idx,
|
|
"name": name,
|
|
"color": color,
|
|
"console_color": console_color,
|
|
"cmd": cmd,
|
|
"log_file": log_file,
|
|
})
|
|
|
|
# Dry-run display mode
|
|
if args.dry_run:
|
|
print(f"\n{CYAN}{'=' * 68}{RESET}")
|
|
print(f"{BOLD}🔍 DRY RUN: {count} {agent_type}(s) [{folder}]{RESET}")
|
|
print(f"{CYAN}{'=' * 68}{RESET}")
|
|
print(f"Model: {BOLD}{args.model}{RESET}")
|
|
print(f"Ollama URL: {args.ollama_url}")
|
|
print(f"Server URL: {args.server_url}")
|
|
print(f"Strength: {args.strength} | Health: {args.health}")
|
|
print(f"Interpreter: {python_bin}")
|
|
print(f"Working Dir: {agent_dir}")
|
|
if not args.no_logs and args.log_dir:
|
|
print(f"Log Dir: {Path(args.log_dir).resolve()}")
|
|
print(f"{CYAN}{'-' * 68}{RESET}")
|
|
|
|
for item in agent_configs:
|
|
cmd_str = " ".join(item["cmd"])
|
|
print(f"[{item['index']}] Name: {BOLD}{item['name']}{RESET}")
|
|
print(f" Color: {item['color']}")
|
|
print(f" Cmd: {cmd_str}\n")
|
|
print(f"{CYAN}{'=' * 68}{RESET}\n")
|
|
return
|
|
|
|
# Normal execution
|
|
print(f"\n{CYAN}{'=' * 68}{RESET}")
|
|
print(f"{BOLD}🚀 Launching {count} {agent_type}(s) [{folder}]{RESET}")
|
|
print(f"Model: {BOLD}{args.model}{RESET}")
|
|
print(f"Ollama URL: {args.ollama_url}")
|
|
print(f"Server URL: {args.server_url}")
|
|
print(f"Strength: {args.strength} | Health: {args.health}")
|
|
print(f"Interpreter: {python_bin}")
|
|
if not args.no_logs and args.log_dir:
|
|
print(f"Logs: {Path(args.log_dir).resolve()}/<agent_name>.log")
|
|
print(f"{CYAN}{'=' * 68}{RESET}")
|
|
|
|
env = os.environ.copy()
|
|
env["PYTHONUNBUFFERED"] = "1"
|
|
env["BOT_SERVER_URL"] = args.server_url
|
|
if "troll" in folder.lower():
|
|
env["TROLL_SERVER_URL"] = args.server_url
|
|
|
|
procs: List[Tuple[str, subprocess.Popen]] = []
|
|
threads: List[threading.Thread] = []
|
|
|
|
interrupted = threading.Event()
|
|
|
|
def handle_signal(signum, frame):
|
|
interrupted.set()
|
|
|
|
# Register signal traps
|
|
old_sigint = signal.signal(signal.SIGINT, handle_signal)
|
|
old_sigterm = signal.signal(signal.SIGTERM, handle_signal)
|
|
|
|
try:
|
|
for item in agent_configs:
|
|
print(f"✨ Spawning {item['console_color']}{item['name']}{RESET} ({item['color']})...")
|
|
proc = subprocess.Popen(
|
|
item["cmd"],
|
|
cwd=str(agent_dir),
|
|
env=env,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
text=True,
|
|
bufsize=1,
|
|
)
|
|
procs.append((item["name"], proc))
|
|
|
|
t = threading.Thread(
|
|
target=stream_output,
|
|
args=(item["name"], item["console_color"], proc, item["log_file"]),
|
|
daemon=True,
|
|
)
|
|
t.start()
|
|
threads.append(t)
|
|
time.sleep(0.15) # Slight stagger for clean initial connections
|
|
|
|
print(f"{CYAN}{'=' * 68}{RESET}")
|
|
print(f"{BOLD}💡 All {len(procs)} agent(s) spawned. Press Ctrl+C at any time to stop.{RESET}")
|
|
print(f"{CYAN}{'=' * 68}{RESET}\n")
|
|
|
|
# Monitor loop
|
|
while not interrupted.is_set():
|
|
# Check if all processes have exited naturally
|
|
if all(p.poll() is not None for _, p in procs):
|
|
break
|
|
interrupted.wait(timeout=0.5)
|
|
|
|
except KeyboardInterrupt:
|
|
interrupted.set()
|
|
finally:
|
|
stop_all_processes(procs)
|
|
signal.signal(signal.SIGINT, old_sigint)
|
|
signal.signal(signal.SIGTERM, old_sigterm)
|