365 lines
13 KiB
Python
365 lines
13 KiB
Python
from __future__ import annotations
|
|
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from urllib.parse import urlparse
|
|
|
|
import click
|
|
|
|
from mlflow.agent.agents import AGENTS, AgentName, AgentTool, detect_installed, get_agent
|
|
from mlflow.agent.setup.prompt import build_prompt
|
|
from mlflow.agent.setup.select import arrow_select
|
|
from mlflow.assistant.config import AssistantConfig, SkillsConfig
|
|
from mlflow.assistant.skill_installer import install_skills
|
|
from mlflow.environment_variables import MLFLOW_TRACKING_URI
|
|
from mlflow.telemetry.events import AgentSetupEvent
|
|
from mlflow.telemetry.track import _record_event
|
|
from mlflow.tracking import MlflowClient
|
|
|
|
|
|
def _resolve_experiment_id(tracking_uri: str, ref: str) -> str:
|
|
"""Return an experiment ID. Path inputs are looked up (or created) via the workspace."""
|
|
if not ref.startswith("/"):
|
|
return ref
|
|
client = MlflowClient(tracking_uri=tracking_uri)
|
|
exp = client.get_experiment_by_name(ref)
|
|
if exp is not None:
|
|
return exp.experiment_id
|
|
experiment_id = client.create_experiment(ref)
|
|
click.secho(f"Created experiment {ref!r} (ID {experiment_id}).", fg="green", err=True)
|
|
return experiment_id
|
|
|
|
|
|
def _prompt_experiment_id(tracking_uri: str) -> str:
|
|
experiment_ref = click.prompt(
|
|
click.style(
|
|
"Experiment ID, or path (auto-created if it doesn't exist)",
|
|
fg="cyan",
|
|
bold=True,
|
|
),
|
|
err=True,
|
|
).strip()
|
|
return _resolve_experiment_id(tracking_uri, experiment_ref)
|
|
|
|
|
|
def _find_available_port(start: int = 5000, end: int = 5100) -> int:
|
|
for port in range(start, end):
|
|
with socket.socket() as s:
|
|
try:
|
|
s.bind(("", port))
|
|
except OSError:
|
|
continue
|
|
return port
|
|
raise click.ClickException(f"No available port found in {start}-{end - 1}.")
|
|
|
|
|
|
def _git_root(start: Path) -> tuple[Path | None, str | None]:
|
|
"""Return (repo_root, reason); the reason explains why repo_root is None."""
|
|
try:
|
|
out = subprocess.run(
|
|
["git", "rev-parse", "--show-toplevel"],
|
|
cwd=start,
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
except FileNotFoundError:
|
|
return None, "Git is not installed."
|
|
except subprocess.CalledProcessError:
|
|
return None, "Not inside a git repository."
|
|
return Path(out.stdout.strip()), None
|
|
|
|
|
|
def _choose_agent(preferred: AgentName | None) -> AgentTool:
|
|
if preferred:
|
|
agent = get_agent(preferred)
|
|
if not agent.is_installed():
|
|
raise click.ClickException(
|
|
f"{agent.display_name} CLI ({agent.binary!r}) not found on PATH."
|
|
)
|
|
return agent
|
|
|
|
installed = detect_installed()
|
|
match installed:
|
|
case []:
|
|
available = ", ".join(a.display_name for a in AGENTS.values())
|
|
raise click.ClickException(
|
|
f"No supported agent CLI found on PATH. Install one of: {available}."
|
|
)
|
|
case [only]:
|
|
click.echo(f"Using {only.display_name} (only installed agent detected).", err=True)
|
|
return only
|
|
case _:
|
|
idx = arrow_select(
|
|
"Multiple agents detected. Select one:",
|
|
[a.display_name for a in installed],
|
|
)
|
|
return installed[idx]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _AssistantTarget:
|
|
"""The in-app Assistant provider that a coding agent maps to."""
|
|
|
|
config_name: str # key under AssistantConfig.providers
|
|
skills_subdir: str # global skills dir relative to home (mirrors provider.resolve_skills_path)
|
|
|
|
|
|
# Coding agents whose CLI doubles as an in-app MLflow Assistant provider.
|
|
_ASSISTANT_PROVIDERS: dict[AgentName, _AssistantTarget] = {
|
|
"claude": _AssistantTarget(config_name="claude_code", skills_subdir=".claude/skills"),
|
|
"codex": _AssistantTarget(config_name="codex", skills_subdir=".codex/skills"),
|
|
}
|
|
|
|
|
|
def _is_localhost_tracking_uri(tracking_uri: str) -> bool:
|
|
"""Whether the localhost-only Assistant API can reach this tracking server."""
|
|
parsed = urlparse(tracking_uri if "://" in tracking_uri else f"http://{tracking_uri}")
|
|
host = (parsed.hostname or "").lower()
|
|
return host in ("localhost", "::1") or host.startswith("127.")
|
|
|
|
|
|
def _prompt_assistant_skills_location(
|
|
target: _AssistantTarget, repo_root: Path
|
|
) -> tuple[SkillsConfig, Path]:
|
|
"""Prompt for where to install the Assistant's skills, mirroring the in-app picker.
|
|
|
|
Returns the `SkillsConfig` to persist and the resolved destination directory.
|
|
"""
|
|
global_dest = Path.home() / target.skills_subdir
|
|
project_dest = repo_root / target.skills_subdir
|
|
choice = arrow_select(
|
|
"Where should the skills be installed?",
|
|
[
|
|
f"Global ({global_dest})",
|
|
f"Project ({project_dest})",
|
|
"Custom location",
|
|
],
|
|
)
|
|
match choice:
|
|
case 0:
|
|
return SkillsConfig(type="global"), global_dest
|
|
case 1:
|
|
return SkillsConfig(type="project"), project_dest
|
|
case _:
|
|
raw = click.prompt(
|
|
click.style("Custom skills directory", fg="cyan", bold=True),
|
|
default=str(global_dest),
|
|
err=True,
|
|
).strip()
|
|
dest = Path(raw).expanduser()
|
|
return SkillsConfig(type="custom", custom_path=str(dest)), dest
|
|
|
|
|
|
def _offer_assistant_setup(agent: AgentTool, tracking_uri: str, repo_root: Path) -> bool | None:
|
|
"""Optionally select `agent` as the in-app MLflow Assistant provider.
|
|
|
|
Only offered for agents that have an Assistant provider and when the tracking
|
|
server is reachable from localhost (the Assistant API is localhost-only). On a
|
|
fresh setup the provider is selected and the user picks where to install its skills
|
|
(global/project/custom, mirroring the in-app config picker). An existing provider
|
|
configuration is preserved (model and skills are left untouched): only the
|
|
selection is updated and skills are not reinstalled.
|
|
|
|
Returns None when the Assistant isn't applicable (no matching provider or the
|
|
tracking server isn't reachable from localhost), False when offered but declined,
|
|
and True when configured.
|
|
"""
|
|
target = _ASSISTANT_PROVIDERS.get(agent.name)
|
|
if target is None or not _is_localhost_tracking_uri(tracking_uri):
|
|
return None
|
|
|
|
if not click.confirm(
|
|
click.style(f"Let {agent.display_name} help you in the MLflow UI?", fg="cyan", bold=True),
|
|
default=True,
|
|
err=True,
|
|
):
|
|
return False
|
|
|
|
config = AssistantConfig.load()
|
|
if existing := config.providers.get(target.config_name):
|
|
# Already configured: preserve the user's model and skills, just select it.
|
|
config.set_provider(target.config_name, model=existing.model)
|
|
config.save()
|
|
click.secho(
|
|
f"Selected {agent.display_name} as the MLflow Assistant provider "
|
|
"(kept your existing configuration).",
|
|
fg="green",
|
|
err=True,
|
|
)
|
|
return True
|
|
|
|
skills_config, skills_dest = _prompt_assistant_skills_location(target, repo_root)
|
|
config.set_provider(target.config_name, model="default")
|
|
config.providers[target.config_name].skills = skills_config
|
|
config.save()
|
|
installed = install_skills(skills_dest)
|
|
click.secho(
|
|
f"Enabled the MLflow Assistant ({agent.display_name}); "
|
|
f"installed {len(installed)} skill(s) to {skills_dest}.",
|
|
fg="green",
|
|
err=True,
|
|
)
|
|
return True
|
|
|
|
|
|
def _run_setup(
|
|
agent_name: AgentName | None,
|
|
print_prompt: bool,
|
|
payload: dict[str, Any],
|
|
) -> tuple[list[str], Path] | None:
|
|
"""Run the interactive setup flow and return the agent launch command, or None for --print."""
|
|
repo_root, reason = _git_root(Path.cwd())
|
|
if repo_root is None:
|
|
click.secho(
|
|
f"{reason} The agent's edits cannot be reviewed or reverted with git.",
|
|
fg="yellow",
|
|
err=True,
|
|
)
|
|
repo_root = Path.cwd()
|
|
|
|
agent = _choose_agent(agent_name)
|
|
payload["agent"] = agent.name
|
|
|
|
experiment_id: str | None = None
|
|
local_server_port: int | None = None
|
|
if tracking_uri := MLFLOW_TRACKING_URI.get():
|
|
click.secho(
|
|
f"Using tracking URI from MLFLOW_TRACKING_URI: {tracking_uri}", fg="green", err=True
|
|
)
|
|
if tracking_uri == "databricks" or tracking_uri.startswith("databricks://"):
|
|
experiment_id = _prompt_experiment_id(tracking_uri)
|
|
else:
|
|
backend_choice = arrow_select(
|
|
"Tracking backend:",
|
|
[
|
|
"Start a new local server",
|
|
"Connect to a Databricks workspace",
|
|
"Enter an existing server URL (e.g. http://localhost:5000)",
|
|
],
|
|
)
|
|
match backend_choice:
|
|
case 0:
|
|
local_server_port = _find_available_port()
|
|
tracking_uri = f"http://127.0.0.1:{local_server_port}"
|
|
click.secho(f"Picked local tracking URI: {tracking_uri}", fg="green", err=True)
|
|
case 1:
|
|
profile = click.prompt(
|
|
click.style(
|
|
"Select a Databricks configuration profile, or leave empty for default",
|
|
fg="cyan",
|
|
bold=True,
|
|
),
|
|
default="",
|
|
show_default=False,
|
|
err=True,
|
|
).strip()
|
|
tracking_uri = f"databricks://{profile}" if profile else "databricks"
|
|
experiment_id = _prompt_experiment_id(tracking_uri)
|
|
case _:
|
|
tracking_uri = click.prompt(
|
|
click.style("Tracking server URL", fg="cyan", bold=True),
|
|
err=True,
|
|
).strip()
|
|
|
|
# Enabling the in-app Assistant installs its own skills at a location the user
|
|
# picks, so only fall back to the project-level skills prompt when the Assistant
|
|
# is not configured.
|
|
assistant_configured = _offer_assistant_setup(agent, tracking_uri, repo_root)
|
|
payload["assistant_configured"] = assistant_configured
|
|
if assistant_configured:
|
|
skills_installed = False
|
|
payload["skills_install_confirmed"] = None
|
|
else:
|
|
skills_choice = arrow_select(
|
|
f"Install MLflow skills at {agent.skills_dir}/ (this project)?",
|
|
["Install", "Skip"],
|
|
)
|
|
skills_installed = skills_choice == 0
|
|
payload["skills_install_confirmed"] = skills_installed
|
|
if skills_installed:
|
|
installed = install_skills(repo_root / agent.skills_dir)
|
|
click.secho(
|
|
f"Wrote {len(installed)} skill(s) to {agent.skills_dir}/:", fg="green", err=True
|
|
)
|
|
for name in installed:
|
|
click.echo(f" - {name}", err=True)
|
|
else:
|
|
click.secho("Skipping skill installation.", fg="yellow", err=True)
|
|
|
|
prompt = build_prompt(
|
|
repo_root,
|
|
agent,
|
|
tracking_uri,
|
|
local_server_port=local_server_port,
|
|
experiment_id=experiment_id,
|
|
skills_installed=skills_installed,
|
|
)
|
|
|
|
if print_prompt:
|
|
click.echo(prompt)
|
|
return None
|
|
|
|
cmd = [agent.binary, *agent.interactive_args, prompt]
|
|
click.echo(err=True)
|
|
click.secho(f"Launching {agent.display_name}...", fg="cyan", err=True)
|
|
return cmd, repo_root
|
|
|
|
|
|
@click.command("setup")
|
|
@click.option(
|
|
"--agent",
|
|
"agent_name",
|
|
type=click.Choice(sorted(AGENTS)),
|
|
default=None,
|
|
help="Coding agent to set up. If omitted, picks from installed agents.",
|
|
)
|
|
@click.option(
|
|
"--print",
|
|
"print_prompt",
|
|
is_flag=True,
|
|
default=False,
|
|
help=(
|
|
"Print the composed task prompt to stdout and exit without launching the agent. "
|
|
"Useful for passing the prompt into a custom invocation, e.g. "
|
|
'`claude --permission-mode auto "$(mlflow agent setup --agent claude --print)"`.'
|
|
),
|
|
)
|
|
def setup(
|
|
agent_name: AgentName | None,
|
|
print_prompt: bool,
|
|
):
|
|
"""[Experimental] Install MLflow skills and launch a coding agent to instrument this repo."""
|
|
click.secho(
|
|
"[Experimental] `mlflow agent setup` is experimental and may change without notice.",
|
|
fg="yellow",
|
|
err=True,
|
|
)
|
|
|
|
success = False
|
|
payload = {
|
|
"agent": None,
|
|
"print_prompt": print_prompt,
|
|
"skills_install_confirmed": None,
|
|
"assistant_configured": None,
|
|
}
|
|
try:
|
|
launch = _run_setup(agent_name, print_prompt, payload)
|
|
success = True
|
|
finally:
|
|
# Record before handing off to the agent's TUI so a force-aborted session
|
|
# (kill -9, terminal closed) doesn't drop the setup event.
|
|
_record_event(AgentSetupEvent, payload, success=success)
|
|
|
|
if launch is None:
|
|
return
|
|
|
|
cmd, cwd = launch
|
|
# Inherit stdio so the agent's TUI takes over until the user exits.
|
|
result = subprocess.run(cmd, cwd=cwd)
|
|
sys.exit(result.returncode)
|