arcade-mcp/arcade/arcade/cli/main.py
Sam Partee 7d9354b4b4
Address alpha release tweaks and bugs (#62)
# Address Alpha Release Tweaks and Bugs

This PR addresses several issues and tweaks identified during the alpha
release:

- **Ensure `~/.arcade` directory exists before writing the config file**
In `arcade/cli/authn.py`, added code to create the `~/.arcade` directory
if it doesn't exist. This prevents errors when writing the configuration
file during the login process.

- **Fix retry logic in process management**  
In `arcade/cli/launcher.py`, corrected an off-by-one error in the retry
logic within the `_manage_processes` function. This ensures that the
process management behaves as expected when retries are exhausted.

- **Allow passing environment variables to the engine process**
(technically this option isn't exposed yet)
Updated the `start_servers`, `_manage_processes`, and `_start_process`
functions in `arcade/cli/launcher.py` to accept an `engine_env`
parameter. This allows custom environment variables to be set for the
engine process. Also, set `GIN_MODE` to `"release"` by default.

- **Handle cases with no critics in evaluations**  
Modified the `EvalCase` class in `arcade/sdk/eval/eval.py` to handle
scenarios where no critics are provided. This avoids potential errors
during the evaluation process when critics are absent. Should add a test
for this.

- **Adjust dependencies in `pyproject.toml`**  
- Moved `uvicorn` to be an optional dependency and included it in the
`fastapi` extra.
- Removed unnecessary development dependencies (`mkdocs`,
`mkdocs-material`, `mkdocstrings`).
  - Ensured that `uvicorn` is updated to version `^0.30.0`.

---------

Co-authored-by: Nate Barbettini <nate@arcade-ai.com>
2024-09-25 07:35:25 -07:00

537 lines
17 KiB
Python

import importlib.util
import os
import readline
import threading
import uuid
import webbrowser
from typing import Any, Optional
from urllib.parse import urlencode
import typer
from rich.console import Console
from rich.markdown import Markdown
from rich.markup import escape
from rich.table import Table
from rich.text import Text
from arcade.cli.authn import LocalAuthCallbackServer, check_existing_login
from arcade.cli.launcher import start_servers
from arcade.cli.utils import (
OrderCommands,
apply_config_overrides,
create_cli_catalog,
display_eval_results,
display_streamed_markdown,
display_tool_messages,
get_tool_messages,
markdownify_urls,
validate_and_get_config,
)
from arcade.client import Arcade
from arcade.client.errors import EngineNotHealthyError, EngineOfflineError
from arcade.core.config_model import Config
cli = typer.Typer(
cls=OrderCommands,
add_completion=False,
no_args_is_help=True,
pretty_exceptions_enable=False,
pretty_exceptions_show_locals=False,
pretty_exceptions_short=True,
)
console = Console()
def _get_config_with_overrides(
force_tls: bool,
force_no_tls: bool,
host_input: str | None = None,
port_input: int | None = None,
) -> Config:
"""
Get the config with CLI-specific optional overrides applied.
"""
config = validate_and_get_config()
if not force_tls and not force_no_tls:
tls_input = None
elif force_no_tls:
tls_input = False
else:
tls_input = True
apply_config_overrides(config, host_input, port_input, tls_input)
return config
@cli.command(help="Log in to Arcade Cloud", rich_help_panel="User")
def login(
host: str = typer.Option(
"cloud.arcade-ai.com",
"-h",
"--host",
help="The Arcade Cloud host to log in to.",
),
) -> None:
"""
Logs the user into Arcade Cloud.
"""
if check_existing_login():
return
# Start the HTTP server in a new thread
state = str(uuid.uuid4())
auth_server = LocalAuthCallbackServer(state)
server_thread = threading.Thread(target=auth_server.run_server)
server_thread.start()
try:
# Open the browser for user login
callback_uri = "http://localhost:9905/callback"
params = urlencode({"callback_uri": callback_uri, "state": state})
login_url = f"https://{host}/api/v1/auth/cli_login?{params}"
console.print("Opening a browser to log you in...")
webbrowser.open(login_url)
# Wait for the server thread to finish
server_thread.join()
except KeyboardInterrupt:
auth_server.shutdown_server()
finally:
if server_thread.is_alive():
server_thread.join() # Ensure the server thread completes and cleans up
@cli.command(help="Log out of Arcade Cloud", rich_help_panel="User")
def logout() -> None:
"""
Logs the user out of Arcade Cloud.
"""
# If ~/.arcade/arcade.toml exists, delete it
config_file_path = os.path.expanduser("~/.arcade/arcade.toml")
if os.path.exists(config_file_path):
os.remove(config_file_path)
console.print("You're now logged out.", style="bold")
else:
console.print("You're not logged in.", style="bold red")
@cli.command(help="Create a new toolkit package directory", rich_help_panel="Tool Development")
def new(
directory: str = typer.Option(os.getcwd(), "--dir", help="tools directory path"),
) -> None:
"""
Creates a new toolkit with the given name, description, and result type.
"""
from arcade.cli.new import create_new_toolkit
try:
create_new_toolkit(directory)
except Exception as e:
error_message = f"❌ Failed to create new Toolkit: {escape(str(e))}"
console.print(error_message, style="bold red")
@cli.command(
help="Show the installed toolkits",
rich_help_panel="Tool Development",
)
def show(
toolkit: Optional[str] = typer.Option(
None, "-t", "--toolkit", help="The toolkit to show the tools of"
),
actor: Optional[str] = typer.Option(None, help="A running actor address to list tools from"),
debug: bool = typer.Option(False, "--debug", "-d", help="Show debug information"),
) -> None:
"""
Show the available tools in an actor or toolkit
"""
try:
catalog = create_cli_catalog(toolkit=toolkit)
# Create a table with Rich library
table = Table(show_header=True, header_style="bold magenta")
table.add_column("Name")
table.add_column("Description")
table.add_column("Toolkit")
table.add_column("Version")
for tool in catalog:
table.add_row(tool.name, tool.description, tool.meta.toolkit, tool.version)
console.print(table)
except Exception as e:
if debug:
raise
error_message = f"❌ Failed to List tools: {escape(str(e))}"
console.print(error_message, style="bold red")
@cli.command(help="Start Arcade Chat in the terminal", rich_help_panel="Launch")
def chat(
model: str = typer.Option("gpt-4o", "-m", help="The model to use for prediction."),
stream: bool = typer.Option(
False, "-s", "--stream", is_flag=True, help="Stream the tool output."
),
debug: bool = typer.Option(False, "--debug", "-d", help="Show debug information"),
host: str = typer.Option(
None,
"-h",
"--host",
help="The Arcade Engine address to send chat requests to.",
),
port: int = typer.Option(
None,
"-p",
"--port",
help="The port of the Arcade Engine.",
),
force_tls: bool = typer.Option(
False,
"--tls",
help="Whether to force TLS for the connection to the Arcade Engine. If not specified, the connection will use TLS if the engine URL uses a 'https' scheme.",
),
force_no_tls: bool = typer.Option(
False,
"--no-tls",
help="Whether to disable TLS for the connection to the Arcade Engine.",
),
) -> None:
"""
Chat with a language model.
"""
config = _get_config_with_overrides(force_tls, force_no_tls, host, port)
client = Arcade(api_key=config.api.key, base_url=config.engine_url)
user_email = config.user.email if config.user else None
user_attribution = f"({user_email})" if user_email else ""
try:
# start messages conversation
history: list[dict[str, Any]] = []
chat_header = Text.assemble(
"\n",
(
"=== Arcade AI Chat ===",
"bold magenta underline",
),
"\n",
"\n",
"Chatting with Arcade Engine at ",
(
config.engine_url,
"bold blue",
),
)
if stream:
chat_header.append(" (streaming)")
console.print(chat_header)
# Try to hit /health endpoint on engine and warn if it is down
log_engine_health(client)
while True:
console.print(f"\n[magenta][bold]User[/bold] {user_attribution}:[/magenta] ")
# Use input() instead of console.input() to leverage readline history
user_input = input()
# Add the input to history
readline.add_history(user_input)
history.append({"role": "user", "content": user_input})
tool_messages: list[dict] = []
if stream:
# TODO Fix this in the client so users don't deal with these
# typing issues
stream_response = client.chat.completions.create( # type: ignore[call-overload]
model=model,
messages=history,
tool_choice="generate",
user=user_email,
stream=True,
)
role, message_content, tool_messages = display_streamed_markdown(
stream_response, model
)
history += tool_messages
else:
response = client.chat.completions.create( # type: ignore[call-overload]
model=model,
messages=history,
tool_choice="generate",
user=user_email,
stream=False,
)
message_content = response.choices[0].message.content or ""
tool_messages = get_tool_messages(response.choices[0])
history += tool_messages
role = response.choices[0].message.role
if role == "assistant":
message_content = markdownify_urls(message_content)
console.print(
f"\n[bold blue]Assistant ({model}):[/bold blue] ", Markdown(message_content)
)
else:
console.print(f"\n[bold magenta]{role}:[/bold magenta] {message_content}")
if debug:
display_tool_messages(tool_messages)
history.append({"role": role, "content": message_content})
except KeyboardInterrupt:
console.print("Chat stopped by user.", style="bold blue")
typer.Exit()
except RuntimeError as e:
error_message = f"❌ Failed to run tool{': ' + escape(str(e)) if str(e) else ''}"
console.print(error_message, style="bold red")
raise typer.Exit()
@cli.command(help="Start a local Arcade Actor server", rich_help_panel="Launch")
def dev(
host: str = typer.Option(
"127.0.0.1", help="Host for the app, from settings by default.", show_default=True
),
port: int = typer.Option(
"8002", "-p", "--port", help="Port for the app, defaults to ", show_default=True
),
disable_auth: bool = typer.Option(
False,
"--no-auth",
help="Disable authentication for the actor. Not recommended for production.",
show_default=True,
),
) -> None:
"""
Starts the actor with host, port, and reload options. Uses
Uvicorn as ASGI actor. Parameters allow runtime configuration.
"""
from arcade.cli.serve import serve_default_actor
try:
serve_default_actor(host, port, disable_auth)
except KeyboardInterrupt:
typer.Exit()
except Exception as e:
error_message = f"❌ Failed to start Arcade Actor: {escape(str(e))}"
console.print(error_message, style="bold red")
raise typer.Exit(code=1)
@cli.command(help="Show/edit the local Arcade configuration", rich_help_panel="User")
def config(
action: str = typer.Argument("show", help="The action to take (show/edit)"),
key: str = typer.Option(
None, "--key", "-k", help="The configuration key to edit (e.g., 'api.key')"
),
val: str = typer.Option(None, "--val", "-v", help="The value of the configuration to edit"),
) -> None:
"""
Show/edit configuration details of the Arcade Engine
"""
config = validate_and_get_config()
if action == "show":
display_config_as_table(config)
elif action == "edit":
if not key or val is None:
console.print("❌ Key and value must be provided for editing.", style="bold red")
raise typer.Exit(code=1)
keys = key.split(".")
if len(keys) != 2:
console.print("❌ Invalid key format. Use 'section.name' format.", style="bold red")
raise typer.Exit(code=1)
section, name = keys
section_dict = getattr(config, section, None)
if section_dict and hasattr(section_dict, name):
setattr(section_dict, name, val)
config.save_to_file()
console.print("✅ Configuration updated successfully.", style="bold green")
else:
console.print(
f"❌ Invalid configuration name: {name} in section: {section}", style="bold red"
)
raise typer.Exit(code=1)
else:
console.print(f"❌ Invalid action: {action}", style="bold red")
raise typer.Exit(code=1)
def log_engine_health(client: Arcade) -> None:
try:
client.health.check()
except EngineNotHealthyError as e:
console.print(
"[bold][yellow]⚠️ Warning: "
+ str(e)
+ " ("
+ "[/yellow]"
+ "[red]"
+ str(e.status_code)
+ "[/red]"
+ "[yellow])[/yellow][/bold]"
)
except EngineOfflineError:
console.print(
"⚠️ Warning: Arcade Engine was unreachable. (Is it running?)",
style="bold yellow",
)
def display_config_as_table(config) -> None: # type: ignore[no-untyped-def]
"""
Display the configuration details as a table using Rich library.
"""
table = Table(show_header=True, header_style="bold magenta")
table.add_column("Section")
table.add_column("Name")
table.add_column("Value")
for section_name in config.model_dump():
section = getattr(config, section_name)
if section:
section = section.dict()
first = True
for name, value in section.items():
if first:
table.add_row(section_name, name, str(value))
first = False
else:
table.add_row("", name, str(value))
table.add_row("", "", "")
console.print(table)
@cli.command(help="Run tool calling evaluations", rich_help_panel="Tool Development")
def evals(
directory: str = typer.Argument(".", help="Directory containing evaluation files"),
show_details: bool = typer.Option(False, "--details", "-d", help="Show detailed results"),
max_concurrent: int = typer.Option(
1,
"--max-concurrent",
"-c",
help="Maximum number of concurrent evaluations (default: 1)",
),
models: str = typer.Option(
"gpt-4o", "--models", "-m", help="The models to use for evaluation (default: gpt-4o)"
),
host: str = typer.Option(
None,
"-h",
"--host",
help="The Arcade Engine address to send chat requests to.",
),
port: int = typer.Option(
None,
"-p",
"--port",
help="The port of the Arcade Engine.",
),
force_tls: bool = typer.Option(
False,
"--tls",
help="Whether to force TLS for the connection to the Arcade Engine. If not specified, the connection will use TLS if the engine URL uses a 'https' scheme.",
),
force_no_tls: bool = typer.Option(
False,
"--no-tls",
help="Whether to disable TLS for the connection to the Arcade Engine.",
),
) -> None:
"""
Find all files starting with 'eval_' in the given directory,
execute any functions decorated with @tool_eval, and display the results.
"""
config = _get_config_with_overrides(force_tls, force_no_tls, host, port)
models = models.split(",") # type: ignore[assignment]
eval_files = [f for f in os.listdir(directory) if f.startswith("eval_") and f.endswith(".py")]
if not eval_files:
console.print("No evaluation files found.", style="bold yellow")
return
if show_details:
console.print(
Text.assemble(
("\nRunning evaluations against Arcade Engine at ", "bold"),
(config.engine_url, "bold blue"),
)
)
# Try to hit /health endpoint on engine and warn if it is down
client = Arcade(api_key=config.api.key, base_url=config.engine_url)
log_engine_health(client)
for file in eval_files:
file_path = os.path.join(directory, file)
module_name = file[:-3] # Remove .py extension
spec = importlib.util.spec_from_file_location(module_name, file_path)
if spec is None:
console.print(f"Failed to load {file}", style="bold red")
continue
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module) # type: ignore[union-attr]
eval_suites = [
obj
for name, obj in module.__dict__.items()
if callable(obj) and hasattr(obj, "__tool_eval__")
]
if not eval_suites:
console.print(f"No @tool_eval functions found in {file}", style="bold yellow")
continue
if show_details:
suite_label = "suite" if len(eval_suites) == 1 else "suites"
console.print(f"\nFound {len(eval_suites)} {suite_label} in {file}", style="bold")
for suite_func in eval_suites:
console.print(
Text.assemble(
("\nRunning evaluations in ", "bold"),
(suite_func.__name__, "bold blue"),
)
)
results = suite_func(config=config, models=models, max_concurrency=max_concurrent)
display_eval_results(results, show_details=show_details)
@cli.command(help="Start an Arcade Cluster instance", rich_help_panel="Launch")
def up(
host: str = typer.Option("127.0.0.1", help="Host for the actor server.", show_default=True),
port: int = typer.Option(
8002, "-p", "--port", help="Port for the actor server.", show_default=True
),
engine_config: str = typer.Option(
None, "-c", "--config", help="Path to the engine configuration file."
),
) -> None:
"""
Start both the actor and engine servers.
"""
try:
# TODO: pass Engine env vars from here
start_servers(host, port, engine_config)
except Exception as e:
error_message = f"❌ Failed to start servers: {escape(str(e))}"
console.print(error_message, style="bold red")
typer.Exit(code=1)