Files
2026-07-13 13:22:34 +08:00

657 lines
28 KiB
Python

"""
Claude Code provider for MLflow Assistant.
This module provides the Claude Code integration for the assistant API,
enabling AI-powered trace analysis through the Claude Code CLI.
"""
import asyncio
import json
import logging
import os
import shutil
import subprocess
from pathlib import Path
from typing import Any, AsyncGenerator, Callable
from mlflow.assistant.providers.base import (
AssistantProvider,
CLINotInstalledError,
NotAuthenticatedError,
load_config,
)
from mlflow.assistant.types import (
ContentBlock,
Event,
Message,
TextBlock,
ThinkingBlock,
ToolResultBlock,
ToolUseBlock,
)
from mlflow.server.assistant.session import clear_process_pid, save_process_pid
_logger = logging.getLogger(__name__)
# Allowed tools for Claude Code CLI
# Restrict to only Bash commands that use MLflow CLI
BASE_ALLOWED_TOOLS = [
"Bash(mlflow:*)",
"Skill", # Skill tool needs to be explicitly allowed
]
FILE_EDIT_TOOLS = [
# Allow writing evaluation scripts, editing code, reading
# project files, etc. in the project directory
"Edit(*)",
"Read(*)",
"Write(*)",
# Allow writing large command output to files in /tmp so it
# can be analyzed with bash commands without loading full contents into context
"Edit(//tmp/**)",
"Read(//tmp/**)",
"Write(//tmp/**)",
]
DOCS_TOOLS = ["WebFetch(domain:mlflow.org)"]
CLAUDE_SYSTEM_PROMPT = """\
You are an MLflow assistant helping users with their MLflow projects. Users interact with
you through the MLflow UI. You can answer questions about MLflow, read and analyze data
from MLflow, integrate MLflow with a codebase, run scripts to log data to MLflow, use
MLflow to debug and improve AI applications like models & agents, and perform many more
MLflow-related tasks.
The following instructions are fundamental to your behavior. You MUST ALWAYS follow them
exactly as specified. You MUST re-read them carefully whenever you start a new response to the user.
Do NOT ignore or skip these instructions under any circumstances!
## CRITICAL: Be Proactive and Minimize User Effort
NEVER ask the user to do something manually that you can do for them.
You MUST always try to minimize the number of steps the user has to take manually. The user
is relying on you to accelerate their workflows. For example, if the user asks for a tutorial on
how to do something, find the answer and then offer to do it for them using MLflow commands or code,
rather than just telling them how to do it themselves.
## CRITICAL: Using Skills
You have Claude Code skills for MLflow tasks. Each skill listed in your available skills has a
description that explains when to use it.
You MUST use skills for anything relating to:
- Onboarding and getting started with MLflow (e.g. new user questions about MLflow)
- Reading or analyzing traces and chat sessions
- Searching for traces and chat sessions
- Searching for MLflow documentation
- Running MLflow GenAI evaluation to evaluate traces or agents
- Querying MLflow metrics
- Anything else explicitly covered by a skill
(you MUST read skill descriptions carefully before acting)
ALWAYS abide by the following rules:
- Before responding to any user message or request, YOU MUST consult your list of available skills
to determine if a relevant skill exists. If a relevant skill exists, you MUST try using it first.
Using the right skill leads to more effective outcomes.
Even if your conversation with the user has many previous messages, EVERY new message from the
user MUST trigger a skills check. Do NOT skip this step.
- When following a skill, you MUST read its instructions VERY carefully —
especially command syntax, which must be followed precisely.
- NEVER run ANY command before checking for a relevant skill. ALWAYS
check for skills first. For example, do not try to consult the CLI
reference for searching traces until you have read the skills for
trace search and analysis first.
## CRITICAL: Complete All Work Before Finishing Your Response
You may provide progress updates throughout the process, but do NOT finish your response until ALL
work — including work done by subagents — is fully complete. The user interacts with you
through a UI that does not support fetching results from async subagents. If you finish
responding before subagent work is done, the user will never see those results. Always wait for
all subagent tasks to finish and include their results in your final response.
## MLflow Server Connection (Pre-configured)
The MLflow tracking server is running at: `{tracking_uri}`
**CRITICAL**:
- The server is ALREADY RUNNING. Never ask the user to start or set up the MLflow server.
- ALL MLflow operations MUST target this server. You must assume MLFLOW_TRACKING_URI env var is.
always set. DO NOT try to override it or set custom env var to the bash command.
- Assume the server is available and operational at all times, unless you have good reason
to believe otherwise (e.g. an error that seems likely caused by server unavailability).
## User Context
The user has already installed MLflow and is working within the MLflow UI. Never instruct the
user to install MLflow or start the MLflow UI/server - these are already set up and running.
Under normal conditions, never verify that the server is running; if the user is using the
MLflow UI, the server is clearly operational. Only check server status when debugging or
investigating a suspected server error.
Since the user is already in the MLflow UI, do NOT unnecessarily reference the server URL in
your responses (e.g., "go to http://localhost:8888" or "refresh your MLflow UI at ...").
Only include URLs when they are specific, actionable links to a particular page in the UI
(e.g., a link to a specific experiment, run, or trace).
User messages may include a <context> block containing JSON that represents what the user is
currently viewing on screen (e.g., traceId, experimentId, selectedTraceIds). Use this context
to understand what entities the user is referring to when they ask questions, as well as
where the user wants to log (write) or update information.
## Command Preferences (IMPORTANT)
### MLflow Read-Only Operations
For querying and reading MLflow data (experiments, runs, traces, metrics, etc.):
* STRONGLY PREFER MLflow CLI commands directly. Try to use the CLI until you are certain
that it cannot accomplish the task. Do NOT mistake syntax errors or your own mistakes
for limitations of the CLI.
* When using MLflow CLI, always use `--help` to discover all available options.
Do not skip this step or you will not get the correct command.
* Trust that MLflow CLI commands will work. Do not add error handling or fallbacks to Python.
* Never combine two bash commands with `&&` or `||`. That will error out.
* If the CLI cannot accomplish the task, fall back to the MLflow SDK.
* When working with large output, write it to files /tmp and use
bash commands to analyze the files, rather than reading the full contents into context.
### MLflow Write Operations
For logging new data to MLflow (traces, runs, metrics, artifacts, etc.):
* The CLI does not support all write operations, so use an MLflow SDK instead.
* Use the appropriate SDK for your working directory's project language
(Python, TypeScript, etc.). Fall back to Python if no project is detected or if
MLflow does not offer an SDK for the detected language.
* Always set the tracking URI before logging (see "MLflow Server Connection" section above).
IMPORTANT: After writing data, always tell the user how to access it. Prefer directing them
to the MLflow UI (provide specific URLs where possible, e.g., `{tracking_uri}/#/experiments/123`).
If the data is not viewable in the UI, explain how to access it via MLflow CLI or API.
### Handling permissions issues
If you require additional permissions to execute a command or perform an action, ALWAYS tell the
user what specific permission(s) you need.
If the permissions are for the MLflow CLI, then the user likely has a permissions override in
their Claude Code settings JSON file or Claude Code hooks. In this case, tell the user to edit
their settings files or hooks to provide the exact permission(s) needed in order to proceed. Give
them the exact permission(s) require in Claude Code syntax.
Otherwise, tell the user to enable full access permissions from the Assistant Settings UI. Also tell
the user that, if full access permissions are already enabled, then they need to check their
Claude Code settings JSON file or Claude Code hooks to ensure there are no permission overrides that
conflict with full access (Claude Code's 'bypassPermissions' mode). Finally, tell the user how to
edit their Claude Code settings or hooks to enable the specific permission(s) needed to proceed.
This gives the user all of the available options and necessary information to resolve permission
issues.
### Data Access
NEVER access the MLflow server's backend storage directly. Always use MLflow APIs or CLIs and
let the server handle storage. Specifically:
- NEVER use the MLflow CLI or API with a database or file tracking URI - only use the configured
HTTP tracking URI (`{tracking_uri}`).
- NEVER use database CLI tools (e.g., sqlite3, psql) to connect directly to the MLflow database.
- NEVER read the filesystem or cloud storage to access MLflow artifact storage directly.
- ALWAYS let the MLflow server handle all storage operations through its APIs.
## MLflow Documentation
If you have a permission to fetch MLflow documentation, use the WebFetch tool to fetch
pages from mlflow.org to provide accurate information about MLflow.
### Accessing Documentation
When reading documentation, ALWAYS start from https://mlflow.org/docs/latest/llms.txt page that
lists links to each pages of the documentation. Start with that page and follow the links to the
relevant pages to get more information.
IMPORTANT: When accessing documentation pages or returning documentation links to users, always use
the latest version URL (https://mlflow.org/docs/latest/...) instead of version-specific URLs.
### CRITICAL: Presenting Documentation Results
IMPORTANT: ALWAYS offer to complete tasks from the documentation results yourself, on behalf of the
user. Since you are capable of executing code, debugging, logging data to MLflow, and much more, do
NOT just return documentation links or excerpts for the user to read and act on themselves.
Only ask the user to do something manually if you have tried and cannot do it yourself, or
if you truly do not know how.
IMPORTANT: When presenting information from documentation, you MUST adapt it to the user's
context (see "User Context" section above). Before responding, thoroughly re-read the User Context
section and adjust your response accordingly. Always consider what the user already has set up
and running. For example:
- Do NOT tell the user to install MLflow or how to install it - it is already installed.
- Do NOT tell the user to start the MLflow server or UI - they are already running.
- Do NOT tell the user to open a browser to view the MLflow UI - they are already using it.
- Skip any setup/installation steps that are already complete for this user.
Focus on the substantive content that is relevant to the user's actual question.
"""
def _build_system_prompt(tracking_uri: str) -> str:
"""
Build the system prompt for the Claude Code assistant.
Args:
tracking_uri: The MLflow tracking server URI (e.g., "http://localhost:5000").
Returns:
The complete system prompt string.
"""
return CLAUDE_SYSTEM_PROMPT.format(tracking_uri=tracking_uri)
class ClaudeCodeProvider(AssistantProvider):
"""Assistant provider using Claude Code CLI."""
@property
def name(self) -> str:
return "claude_code"
@property
def display_name(self) -> str:
return "Claude Code"
@property
def description(self) -> str:
return "AI-powered assistant using Claude Code CLI"
def is_available(self) -> bool:
return shutil.which("claude") is not None
def check_connection(self, echo: Callable[[str], None] | None = None) -> None:
"""
Check if Claude CLI is installed and authenticated.
Args:
echo: Optional function to print status messages.
Raises:
ProviderNotConfiguredError: If CLI is not installed or not authenticated.
"""
claude_path = shutil.which("claude")
if not claude_path:
if echo:
echo("Claude CLI not found")
raise CLINotInstalledError(
"Claude Code CLI is not installed. "
"Install it with: npm install -g @anthropic-ai/claude-code"
)
if echo:
echo(f"Claude CLI found: {claude_path}")
echo("Checking connection... (this may take a few seconds)")
# Check authentication by running a minimal test prompt
try:
result = subprocess.run(
["claude", "-p", "hi", "--max-turns", "1", "--output-format", "json"],
capture_output=True,
text=True,
timeout=30,
)
if result.returncode == 0:
if echo:
echo("Authentication verified")
return
stderr = result.stderr.lower()
if "auth" in stderr or "login" in stderr or "unauthorized" in stderr:
error_msg = "Not authenticated. Please run: claude login"
else:
error_msg = result.stderr.strip() or f"Process exited with code {result.returncode}"
if echo:
echo(f"Authentication failed: {error_msg}")
raise NotAuthenticatedError(error_msg)
except subprocess.TimeoutExpired:
if echo:
echo("Authentication check timed out")
raise NotAuthenticatedError("Authentication check timed out")
except subprocess.SubprocessError as e:
if echo:
echo(f"Error checking authentication: {e}")
raise NotAuthenticatedError(str(e))
def resolve_skills_path(self, base_directory: Path) -> Path:
"""Resolve the path to the skills directory."""
return base_directory / ".claude" / "skills"
async def astream(
self,
prompt: str,
tracking_uri: str,
session_id: str | None = None,
mlflow_session_id: str | None = None,
cwd: Path | None = None,
context: dict[str, Any] | None = None,
) -> AsyncGenerator[Event, None]:
"""
Stream responses from Claude Code CLI asynchronously.
Args:
prompt: The prompt to send to Claude
tracking_uri: MLflow tracking server URI for the assistant to use
session_id: Claude session ID for resume
mlflow_session_id: MLflow session ID for PID tracking (enables cancellation)
cwd: Working directory for Claude Code CLI
context: Additional context for the assistant, such as information from
the current UI page the user is viewing (e.g., experimentId, traceId)
Yields:
Event objects
"""
claude_path = shutil.which("claude")
if not claude_path:
yield Event.from_error(
"Claude CLI not found. Please install Claude Code CLI and ensure it's in your PATH."
)
return
# Build user message with context
if context:
user_message = f"<context>\n{json.dumps(context)}\n</context>\n\n{prompt}"
else:
user_message = prompt
# Build command
# Note: --verbose is required when using --output-format=stream-json with -p
cmd = [claude_path, "-p", user_message, "--output-format", "stream-json", "--verbose"]
# Add system prompt with tracking URI context
system_prompt = _build_system_prompt(tracking_uri)
cmd.extend(["--append-system-prompt", system_prompt])
config = load_config(self.name)
# Handle permission mode
if config.permissions.full_access:
# Full access mode - bypass all permission checks
cmd.extend(["--permission-mode", "bypassPermissions"])
else:
# Build allowed tools list based on permissions
allowed_tools = list(BASE_ALLOWED_TOOLS)
if config.permissions.allow_edit_files:
allowed_tools.extend(FILE_EDIT_TOOLS)
if config.permissions.allow_read_docs:
allowed_tools.extend(DOCS_TOOLS)
for tool in allowed_tools:
cmd.extend(["--allowed-tools", tool])
if config.model and config.model != "default":
cmd.extend(["--model", config.model])
if session_id:
cmd.extend(["--resume", session_id])
process = None
try:
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=cwd,
# Increase buffer limit from default 64KB to handle large JSON responses
# from Claude Code CLI (e.g., tool results containing large file contents)
limit=100 * 1024 * 1024, # 100 MB
# Specify tracking URI to let Claude Code CLI inherit it
# NB: `env` arg in `create_subprocess_exec` does not merge with the parent process's
# environment so we need to copy the parent process's environment explicitly.
env={**os.environ.copy(), "MLFLOW_TRACKING_URI": tracking_uri},
)
# Save PID for cancellation support
if mlflow_session_id and process.pid:
save_process_pid(mlflow_session_id, process.pid)
try:
if process.stdout is None:
raise RuntimeError("Claude CLI stdout pipe was not created")
async for line in process.stdout:
line_str = line.decode("utf-8").strip()
if not line_str:
continue
try:
data = json.loads(line_str)
if self._should_filter_out_message(data):
continue
# Emit token usage before the result event, which closes
# the stream on the client once received.
if data.get("type") == "result" and (usage := data.get("usage")):
yield self._build_usage_event(usage, data.get("total_cost_usd"))
if msg := self._parse_message_to_event(data):
yield msg
except json.JSONDecodeError:
# Non-JSON output, treat as plain text
yield Event.from_message(Message(role="user", content=line_str))
finally:
# Clear PID when done (regardless of how we exit)
if mlflow_session_id:
clear_process_pid(mlflow_session_id)
# Wait for process to complete
await process.wait()
# Check if killed by interrupt (SIGKILL = -9)
if process.returncode == -9:
yield Event.from_interrupted()
return
if process.returncode != 0:
stderr = b""
if process.stderr is not None:
stderr = await process.stderr.read()
error_msg = (
stderr.decode("utf-8").strip()
or f"Process exited with code {process.returncode}"
)
yield Event.from_error(error_msg)
except Exception as e:
_logger.exception("Error running Claude Code CLI")
yield Event.from_error(str(e))
finally:
if process is not None and process.returncode is None:
process.kill()
await process.wait()
@staticmethod
def _build_usage_event(usage: dict[str, Any], cost_usd: float | None = None) -> Event:
"""Translate Claude Code CLI token usage into a UI usage stream event.
The CLI splits input tokens across fresh input and prompt-cache
reads/writes, but the model processes all of them, so they're summed
into prompt_tokens to reflect the true context size. The CLI also
reports an authoritative dollar cost, which we pass through directly
rather than recomputing. The shape matches the usage event emitted by
the gateway provider so the UI handles both identically.
"""
prompt_tokens = (
(usage.get("input_tokens") or 0)
+ (usage.get("cache_creation_input_tokens") or 0)
+ (usage.get("cache_read_input_tokens") or 0)
)
completion_tokens = usage.get("output_tokens") or 0
return Event.from_stream_event({
"type": "usage",
"usage": {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": prompt_tokens + completion_tokens,
"total_cost_usd": cost_usd,
},
})
def _parse_message_to_event(self, data: dict[str, Any]) -> Event | None:
"""
Parse json message from Claude Code CLI output.
Reference: https://github.com/anthropics/claude-agent-sdk-python/blob/29c12cd80b256e88f321b2b8f1f5a88445077aa5/src/claude_agent_sdk/_internal/message_parser.py#L24
Args:
data: Raw message dictionary from CLI output
Returns:
Parsed Event object
"""
message_type = data.get("type")
if not message_type:
return Event.from_error("Message missing 'type' field")
match message_type:
case "user":
try:
if isinstance(data["message"]["content"], list):
user_content_blocks = []
for block in data["message"]["content"]:
match block["type"]:
case "text":
user_content_blocks.append(TextBlock(text=block["text"]))
case "tool_use":
user_content_blocks.append(
ToolUseBlock(
id=block["id"],
name=block["name"],
input=block["input"],
)
)
case "tool_result":
user_content_blocks.append(
ToolResultBlock(
tool_use_id=block["tool_use_id"],
content=block.get("content"),
is_error=block.get("is_error"),
)
)
msg = Message(role="user", content=user_content_blocks)
else:
msg = Message(role="user", content=data["message"]["content"])
return Event.from_message(msg)
except KeyError as e:
return Event.from_error(f"Failed to parse user message: {e}")
case "assistant":
try:
if data["message"].get("error"):
return Event.from_error(data["message"]["error"])
content_blocks: list[ContentBlock] = []
for block in data["message"]["content"]:
match block["type"]:
case "text":
content_blocks.append(TextBlock(text=block["text"]))
case "thinking":
content_blocks.append(
ThinkingBlock(
thinking=block["thinking"],
signature=block["signature"],
)
)
case "tool_use":
content_blocks.append(
ToolUseBlock(
id=block["id"],
name=block["name"],
input=block["input"],
)
)
case "tool_result":
content_blocks.append(
ToolResultBlock(
tool_use_id=block["tool_use_id"],
content=block.get("content"),
is_error=block.get("is_error"),
)
)
msg = Message(role="assistant", content=content_blocks)
return Event.from_message(msg)
except KeyError as e:
return Event.from_error(f"Failed to parse assistant message: {e}")
case "system":
# NB: Skip system message. The system message from Claude Code CLI contains
# the various metadata about runtime, which is not used by the assistant UX.
return None
case "error":
try:
error_msg = data.get("error", {}).get("message", str(data.get("error")))
return Event.from_error(error_msg)
except Exception as e:
return Event.from_error(f"Failed to parse error message: {e}")
case "result":
try:
return Event.from_result(
result=data.get("result"),
session_id=data["session_id"],
)
except KeyError as e:
return Event.from_error(f"Failed to parse result message: {e}")
case "stream_event":
try:
return Event.from_stream_event(event=data["event"])
except KeyError as e:
return Event.from_error(f"Failed to parse stream_event message: {e}")
case "rate_limit_event":
# rate_limit_event is a status event emitted by the CLI to report
# rate limit info. Only surface a message to the user when they are
# actually limited, not on every status update.
info = data.get("rate_limit_info", {})
if info.get("status") == "limited":
resets_at = info.get("resetsAt")
msg = "You've hit a rate limit — please wait a moment and try again."
if resets_at:
msg += f" Your limit resets at {resets_at}."
return Event.from_message(
Message(role="assistant", content=[TextBlock(text=msg)])
)
return None
case _:
_logger.warning("Unexpected message type from CLI: %s", message_type)
return None
def _should_filter_out_message(self, data: dict[str, Any]) -> bool:
"""
Check if an internal message that should be filtered out before being displayed to the user.
Currently filters:
- Skill prompt messages: When a Skill tool is called, Claude Code sends an internal
user message containing the full skill instructions (starting with "Base directory
for this skill:"). These messages are internal and should not be displayed to users.
"""
if data.get("type") != "user":
return False
content = data.get("message", {}).get("content", [])
if not isinstance(content, list):
return False
return any(
block.get("type") == "text"
# TODO: This prefix is not guaranteed to be stable. We should find a better way to
# filter out these messages.
and block.get("text", "").startswith("Base directory for this skill:")
for block in content
)