| import json |
| import os |
| import re |
| from pathlib import Path |
| from typing import Any, Literal, Union |
|
|
| from dotenv import load_dotenv |
| from fastmcp.mcp_config import ( |
| RemoteMCPServer, |
| StdioMCPServer, |
| ) |
| from pydantic import BaseModel |
|
|
| from agent.messaging.models import MessagingConfig |
|
|
| |
| MCPServerConfig = Union[StdioMCPServer, RemoteMCPServer] |
|
|
| |
| _PROJECT_ROOT = Path(__file__).resolve().parent.parent |
|
|
|
|
| class Config(BaseModel): |
| """Configuration manager""" |
|
|
| model_name: str |
| mcpServers: dict[str, MCPServerConfig] = {} |
| save_sessions: bool = True |
| session_dataset_repo: str = "smolagents/ml-intern-sessions" |
| |
| |
| |
| |
| |
| share_traces: bool = True |
| personal_trace_repo_template: str = "{hf_user}/ml-intern-sessions" |
| auto_save_interval: int = 1 |
| |
| |
| |
| |
| heartbeat_interval_s: int = 60 |
| yolo_mode: bool = False |
| max_iterations: int = 300 |
|
|
| |
| confirm_cpu_jobs: bool = True |
| auto_file_upload: bool = False |
| tool_runtime: Literal["local", "sandbox"] = "local" |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| reasoning_effort: str | None = "max" |
| messaging: MessagingConfig = MessagingConfig() |
|
|
|
|
| USER_CONFIG_ENV_VAR = "ML_INTERN_CLI_CONFIG" |
| DEFAULT_USER_CONFIG_PATH = ( |
| Path.home() / ".config" / "ml-intern" / "cli_agent_config.json" |
| ) |
| SLACK_DEFAULT_DESTINATION = "slack.default" |
| SLACK_DEFAULT_AUTO_EVENT_TYPES = ["approval_required", "error", "turn_complete"] |
|
|
|
|
| def _deep_merge_config( |
| base: dict[str, Any], override: dict[str, Any] |
| ) -> dict[str, Any]: |
| merged = dict(base) |
| for key, value in override.items(): |
| current = merged.get(key) |
| if isinstance(current, dict) and isinstance(value, dict): |
| merged[key] = _deep_merge_config(current, value) |
| else: |
| merged[key] = value |
| return merged |
|
|
|
|
| def _load_json_config(path: Path) -> dict[str, Any]: |
| with open(path, "r", encoding="utf-8") as f: |
| data = json.load(f) |
| if not isinstance(data, dict): |
| raise ValueError(f"Config file {path} must contain a JSON object") |
| return data |
|
|
|
|
| def _load_user_config() -> dict[str, Any]: |
| raw_path = os.environ.get(USER_CONFIG_ENV_VAR) |
| if raw_path: |
| path = Path(raw_path).expanduser() |
| if not path.exists(): |
| raise FileNotFoundError( |
| f"{USER_CONFIG_ENV_VAR} points to missing config file: {path}" |
| ) |
| return _load_json_config(path) |
|
|
| if DEFAULT_USER_CONFIG_PATH.exists(): |
| return _load_json_config(DEFAULT_USER_CONFIG_PATH) |
| return {} |
|
|
|
|
| def _env_bool(name: str, default: bool) -> bool: |
| value = os.environ.get(name) |
| if value is None: |
| return default |
| normalized = value.strip().lower() |
| if normalized in {"1", "true", "yes", "on"}: |
| return True |
| if normalized in {"0", "false", "no", "off"}: |
| return False |
| return default |
|
|
|
|
| def _env_list(name: str) -> list[str] | None: |
| value = os.environ.get(name) |
| if value is None: |
| return None |
| return [item.strip() for item in value.split(",") if item.strip()] |
|
|
|
|
| def apply_slack_user_defaults(raw_config: dict[str, Any]) -> dict[str, Any]: |
| """Enable a default Slack destination from user env vars, when present.""" |
| if not _env_bool("ML_INTERN_SLACK_NOTIFICATIONS", True): |
| return raw_config |
|
|
| token = os.environ.get("SLACK_BOT_TOKEN") |
| channel = os.environ.get("SLACK_CHANNEL_ID") or os.environ.get("SLACK_CHANNEL") |
| if not token or not channel: |
| return raw_config |
|
|
| config = dict(raw_config) |
| messaging = dict(config.get("messaging") or {}) |
| destinations = dict(messaging.get("destinations") or {}) |
| destination_name = ( |
| os.environ.get("ML_INTERN_SLACK_DESTINATION") or SLACK_DEFAULT_DESTINATION |
| ).strip() |
|
|
| if destination_name not in destinations: |
| destinations[destination_name] = { |
| "provider": "slack", |
| "token": token, |
| "channel": channel, |
| "allow_agent_tool": _env_bool("ML_INTERN_SLACK_ALLOW_AGENT_TOOL", True), |
| "allow_auto_events": _env_bool("ML_INTERN_SLACK_ALLOW_AUTO_EVENTS", True), |
| } |
|
|
| auto_events = _env_list("ML_INTERN_SLACK_AUTO_EVENTS") |
| if auto_events is not None: |
| messaging["auto_event_types"] = auto_events |
| elif "auto_event_types" not in messaging: |
| messaging["auto_event_types"] = SLACK_DEFAULT_AUTO_EVENT_TYPES |
|
|
| messaging["enabled"] = True |
| messaging["destinations"] = destinations |
| config["messaging"] = messaging |
| return config |
|
|
|
|
| def substitute_env_vars(obj: Any) -> Any: |
| """ |
| Recursively substitute environment variables in any data structure. |
| |
| Supports ${VAR_NAME} syntax for required variables and ${VAR_NAME:-default} for optional. |
| """ |
| if isinstance(obj, str): |
| pattern = r"\$\{([^}:]+)(?::(-)?([^}]*))?\}" |
|
|
| def replacer(match): |
| var_name = match.group(1) |
| has_default = match.group(2) is not None |
| default_value = match.group(3) if has_default else None |
|
|
| env_value = os.environ.get(var_name) |
|
|
| if env_value is not None: |
| return env_value |
| elif has_default: |
| return default_value or "" |
| else: |
| raise ValueError( |
| f"Environment variable '{var_name}' is not set. " |
| f"Add it to your .env file." |
| ) |
|
|
| return re.sub(pattern, replacer, obj) |
|
|
| elif isinstance(obj, dict): |
| return {key: substitute_env_vars(value) for key, value in obj.items()} |
|
|
| elif isinstance(obj, list): |
| return [substitute_env_vars(item) for item in obj] |
|
|
| return obj |
|
|
|
|
| def load_config( |
| config_path: str = "config.json", |
| include_user_defaults: bool = False, |
| ) -> Config: |
| """ |
| Load configuration with environment variable substitution. |
| |
| Use ${VAR_NAME} in your JSON for any secret. |
| Automatically loads from .env file. |
| """ |
| |
| |
| load_dotenv(_PROJECT_ROOT / ".env") |
| load_dotenv(override=False) |
|
|
| raw_config = _load_json_config(Path(config_path)) |
| if include_user_defaults: |
| raw_config = _deep_merge_config(raw_config, _load_user_config()) |
| raw_config = apply_slack_user_defaults(raw_config) |
|
|
| config_with_env = substitute_env_vars(raw_config) |
| return Config.model_validate(config_with_env) |
|
|