arcade-mcp/arcade/arcade/cli/launcher.py
Sam Partee 68a4caff98
CLI Engine Env passing and Tool Executor cleanup (#95)
This PR introduces the following changes:

- **Engine Environment Configuration**: Adds support for specifying an
environment variables file for the engine via the `arcade dev` CLI
command.
- **Configuration File Handling**: Refactors configuration file handling
in the CLI launcher to generalize logic for locating configuration
files.
- **Tool Execution Logging**: Enhances logging in `BaseActor` to include
execution duration and adjusts logging levels for better visibility.
- **Enhanced Tool Exception Handling**: Improves exception handling in
`ToolExecutor` and updates the `@tool` decorator to ensure proper
propagation and handling of exceptions raised during tool execution.
2024-10-07 17:49:34 -07:00

436 lines
13 KiB
Python

import io
import ipaddress
import logging
import os
import shutil
import signal
import subprocess
import sys
import threading
import time
from pathlib import Path
from typing import Callable
from rich.console import Console
console = Console(highlight=False)
logger = logging.getLogger(__name__)
def start_servers(
host: str,
port: int,
engine_config: str | None,
engine_env: str | None = None,
debug: bool = False,
) -> None:
"""
Start the actor and engine servers.
Args:
host: Host for the actor server.
port: Port for the actor server.
engine_config: Path to the engine configuration file.
engine_env: Path to the engine environment file.
debug: Whether to run in debug mode.
"""
# Validate host and port
host = _validate_host(host)
port = _validate_port(port)
# Ensure engine_config is provided and validated
engine_config = _get_config_file(engine_config, default_filename="engine.yaml")
# Ensure engine_env is provided or found and either way, validated
env_file = _get_config_file(engine_env, default_filename="arcade.env")
# Prepare command-line arguments for the actor server and engine
actor_cmd = _build_actor_command(host, port, debug)
# even if the user didn't pass an env file we may have found it in the default locations
engine_cmd = _build_engine_command(engine_config, engine_env=env_file if env_file else None)
# Start and manage the processes
_manage_processes(actor_cmd, engine_cmd, debug=debug)
def _validate_host(host: str) -> str:
"""
Validates the host input.
Args:
host: Host for the actor server.
Returns:
The validated host as a string.
Raises:
ValueError: If the host is invalid.
"""
try:
# Validate IP address
ipaddress.ip_address(host)
except ValueError:
# Optionally, validate hostname
if not host.isalnum() and "-" not in host and "." not in host:
console.print(f"❌ Invalid host: {host}", style="bold red")
raise ValueError("Invalid host.")
return host
def _validate_port(port: int) -> int:
"""
Validates the port input.
Args:
port: Port for the actor server.
Returns:
The validated port as an integer.
Raises:
ValueError: If the port is out of the valid range.
"""
if not (1 <= port <= 65535):
console.print(f"❌ Invalid port: {port}", style="bold red")
raise ValueError("Invalid port.")
return port
def _get_config_file(file_path: str | None, default_filename: str = "engine.yaml") -> str:
"""
Determines and validates the config file path.
Args:
file_path: Optional path provided by the user.
default_filename: The default filename to look for.
Returns:
The resolved config file path.
Raises:
RuntimeError: If the config file is not found.
"""
if file_path:
config_path = Path(os.path.expanduser(file_path)).resolve()
if not config_path.is_file():
console.print(f"❌ Config file not found at {config_path}", style="bold red")
raise RuntimeError(f"Config file not found at {config_path}")
return str(config_path)
# Look for the file in the current working directory
config_path = Path(os.getcwd()) / default_filename
if config_path.is_file():
console.print(f"Using config file at {config_path}", style="bold green")
return str(config_path)
# Look for the file in the user's home directory under .arcade/
home_config_path = Path.home() / ".arcade" / default_filename
if home_config_path.is_file():
console.print(f"Using config file at {home_config_path}", style="bold green")
return str(home_config_path)
console.print(
f"❌ Config file '{default_filename}' not found in any of the default locations.",
style="bold red",
)
raise RuntimeError(f"Config file '{default_filename}' not found.")
def _build_actor_command(host: str, port: int, debug: bool) -> list[str]:
"""
Builds the command to start the actor server.
Args:
host: Host for the actor server.
port: Port for the actor server.
debug: Whether to run in debug mode.
Returns:
The command as a list.
"""
# Expand full path to "arcade" executable
arcade_bin = shutil.which("arcade")
if not arcade_bin:
console.print(
"❌ Arcade binary not found, please install with `pip install arcade-ai`",
style="bold red",
)
sys.exit(1)
cmd = [
arcade_bin,
"actorup",
"--host",
host,
"--port",
str(port),
]
if debug:
cmd.append("--debug")
return cmd
def _build_engine_command(engine_config: str, engine_env: str | None = None) -> list[str]:
"""
Builds the command to start the engine.
Args:
engine_config: Path to the engine configuration file.
engine_env: Path to the engine environment file.
Returns:
The command as a list.
"""
engine_bin = shutil.which("arcade-engine")
if not engine_bin:
console.print(
"❌ Engine binary not found, refer to the installation guide at "
"https://docs.arcade-ai.com/guides/installation for how to install the engine",
style="bold red",
)
sys.exit(1)
cmd = [
engine_bin,
"-c",
engine_config,
]
if engine_env:
cmd.append("-e")
cmd.append(engine_env)
return cmd
def _manage_processes(
actor_cmd: list[str],
engine_cmd: list[str],
engine_env: dict[str, str] | None = None,
debug: bool = False,
) -> None:
"""
Manages the lifecycle of the actor and engine processes.
Args:
actor_cmd: The command to start the actor server.
engine_cmd: The command to start the engine.
engine_env: Environment variables to set for the engine.
debug: Whether to run in debug mode.
"""
actor_process: subprocess.Popen | None = None
engine_process: subprocess.Popen | None = None
def terminate_processes(exit_program: bool = False) -> None:
console.print("Terminating child processes...", style="bold yellow")
_terminate_process(actor_process)
_terminate_process(engine_process)
if exit_program:
sys.exit(0)
_setup_signal_handlers(terminate_processes)
retry_count = 0
max_retries = 1 # Define the maximum number of retries
while retry_count <= max_retries:
try:
# Start the actor server
console.print("Starting actor server...", style="bold green")
actor_process = _start_process("Actor", actor_cmd, debug=debug)
# Wait a bit to ensure actor is up
time.sleep(2)
# Start the engine
console.print("Starting engine...", style="bold green")
engine_process = _start_process("Engine", engine_cmd, env=engine_env, debug=debug)
# Monitor processes
_monitor_processes(actor_process, engine_process)
# If we reach here, one of the processes has exited
retry_count += 1
console.print(
f"Processes exited. Retry {retry_count} of {max_retries}.", style="bold yellow"
)
if retry_count >= max_retries:
console.print(f"❌ Exiting after {max_retries} retries", style="bold red")
terminate_processes(exit_program=True)
break # Exit the loop
except Exception as e:
console.print(f"❌ Exception occurred: {e}", style="bold red")
terminate_processes()
retry_count += 1
if retry_count > max_retries:
console.print(
f"❌ Exiting after {retry_count - 1} retries due to exceptions",
style="bold red",
)
sys.exit(1)
break # Not strictly necessary, but good practice
console.print("Exiting...", style="bold red")
sys.exit(1)
def _start_process(
name: str, cmd: list[str], env: dict[str, str] | None = None, debug: bool = False
) -> subprocess.Popen:
"""
Starts a subprocess and begins streaming its output.
Args:
name: Name of the process.
cmd: Command to execute.
env: Environment variables to set for the process.
debug: Whether to run in debug mode.
Returns:
The subprocess.Popen object.
Raises:
RuntimeError: If the process fails to start.
"""
_env = os.environ.copy()
if env:
_env.update(env)
if debug:
_env["GIN_MODE"] = "debug"
else:
_env["GIN_MODE"] = "release"
if name == "Actor":
_env["PYTHONUNBUFFERED"] = "1"
try:
process = subprocess.Popen( # noqa: S603, RUF100
cmd,
env=_env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True,
bufsize=1,
shell=False,
)
_stream_output(process, name)
return process # noqa: TRY300
except Exception as e:
console.print(f"❌ Failed to start {name}: {e}", style="bold red")
raise RuntimeError(f"Failed to start {name}")
def _stream_output(process: subprocess.Popen, name: str) -> None:
"""
Streams the output from a subprocess to the console.
Args:
process: The subprocess.Popen object.
name: Name of the process.
"""
stdout_style = "green" if name == "Actor" else "#87CEFA"
def stream(pipe: io.TextIOWrapper | None, style: str) -> None:
if pipe is None:
return
with pipe:
for line in iter(pipe.readline, ""):
line = line.rstrip()
if "DEBUG" in line:
line = line.replace("DEBUG", "[#87CEFA]DEBUG[/#87CEFA]", 1)
if "INFO" in line:
line = line.replace("INFO", "[#109a10]INFO[/#109a10]", 1)
if "WARNING" in line:
line = line.replace("WARNING", "[#FFA500]WARNING[/#FFA500]", 1)
if "ERROR" in line:
line = line.replace("ERROR", "[#FF0000]ERROR[/#FF0000]", 1)
console.print(f"[{style}]{name}>[/{style}] {line}")
threading.Thread(target=stream, args=(process.stdout, stdout_style), daemon=True).start()
threading.Thread(target=stream, args=(process.stderr, "red"), daemon=True).start()
def _monitor_processes(actor_process: subprocess.Popen, engine_process: subprocess.Popen) -> None:
"""
Monitors the actor and engine processes, restarts them if they exit.
Args:
actor_process: The actor subprocess.
engine_process: The engine subprocess.
"""
while True:
actor_status = actor_process.poll()
engine_status = engine_process.poll()
if actor_status is not None or engine_status is not None:
if actor_status is not None:
console.print(
f"Actor process exited with code {actor_status}. Restarting both processes...",
style="bold red",
)
if engine_status is not None:
console.print(
f"Engine process exited with code {engine_status}. Restarting both processes...",
style="bold red",
)
_terminate_process(actor_process)
_terminate_process(engine_process)
time.sleep(1)
break # Exit to restart both processes
else:
time.sleep(1)
def _terminate_process(process: subprocess.Popen | None) -> None:
"""
Terminates a subprocess if it's running.
Args:
process: The subprocess.Popen object.
"""
if process and process.poll() is None:
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
def _setup_signal_handlers(terminate_processes: Callable[[bool], None]) -> None:
"""
Setup signal handlers to handle process termination signals.
Args:
terminate_processes: Function to call to terminate child processes.
"""
signals_to_handle = ["SIGINT", "SIGTERM", "SIGQUIT", "SIGHUP"]
for sig_name in signals_to_handle:
sig = getattr(signal, sig_name, None)
if sig is None:
continue # Signal not available on this platform
try:
# Use a lambda to pass the terminate_processes function
signal.signal(
sig,
lambda signum, frame: _handle_signal(signum, terminate_processes),
)
except (ValueError, RuntimeError):
# Signal handling not allowed in this thread or invalid signal
console.print(f"Warning: Cannot set handler for {sig_name}", style="bold yellow")
continue
def _handle_signal(signum: int, terminate_processes: Callable[[bool], None]) -> None:
"""
Handle received signal and terminate child processes.
Args:
signum: The signal number received.
terminate_processes: Function to call to terminate child processes.
"""
signal_name = signal.Signals(signum).name
console.print(f"Received {signal_name}. Shutting down...", style="bold yellow")
terminate_processes(exit_program=True) # type: ignore[call-arg]