"""Model configuration — secrets-free config for ACE roles. ``ModelConfig`` describes which model to use and how. No API keys — those come from the environment (via ``.env`` or exported variables). ``ACEModelConfig`` maps ACE roles (agent, reflector, skill_manager) to individual ``ModelConfig`` instances, enabling per-role model selection. Config is persisted in ``ace.toml`` (committable, no secrets). Keys are persisted in ``.env`` (gitignored). """ from __future__ import annotations import logging import tomllib from dataclasses import dataclass, fields from pathlib import Path from typing import Any logger = logging.getLogger(__name__) CONFIG_FILENAME = "ace.toml" ENV_FILENAME = ".env" # --------------------------------------------------------------------------- # ModelConfig # --------------------------------------------------------------------------- @dataclass class ModelConfig: """Configuration for a single LLM role. No secrets.""" model: str temperature: float = 0.0 max_tokens: int = 2048 extra_params: dict[str, Any] | None = None def to_dict(self) -> dict[str, Any]: """Serialise to a dict, omitting None/default values.""" d: dict[str, Any] = {"model": self.model} if self.temperature != 0.0: d["temperature"] = self.temperature if self.max_tokens != 2048: d["max_tokens"] = self.max_tokens if self.extra_params: d["extra_params"] = self.extra_params return d @classmethod def from_dict(cls, d: dict[str, Any]) -> ModelConfig: known = {f.name for f in fields(cls)} return cls(**{k: v for k, v in d.items() if k in known}) # --------------------------------------------------------------------------- # ACEModelConfig # --------------------------------------------------------------------------- @dataclass class ACEModelConfig: """Model selection per ACE role. No secrets — keys come from env.""" default: ModelConfig agent: ModelConfig | None = None reflector: ModelConfig | None = None skill_manager: ModelConfig | None = None def for_role(self, role: str) -> ModelConfig: """Return the ModelConfig for *role*, falling back to default.""" explicit = getattr(self, role, None) if explicit is not None: return explicit return self.default # -- Serialisation -------------------------------------------------------- def to_dict(self) -> dict[str, Any]: d: dict[str, Any] = {"default": self.default.to_dict()} for role in ("agent", "reflector", "skill_manager"): cfg = getattr(self, role) if cfg is not None: d[role] = cfg.to_dict() return d @classmethod def from_dict(cls, d: dict[str, Any]) -> ACEModelConfig: default = ModelConfig.from_dict(d["default"]) agent = ModelConfig.from_dict(d["agent"]) if "agent" in d else None reflector = ModelConfig.from_dict(d["reflector"]) if "reflector" in d else None skill_manager = ( ModelConfig.from_dict(d["skill_manager"]) if "skill_manager" in d else None ) return cls( default=default, agent=agent, reflector=reflector, skill_manager=skill_manager, ) # --------------------------------------------------------------------------- # TOML persistence # --------------------------------------------------------------------------- def _to_toml(config: ACEModelConfig) -> str: """Serialise ACEModelConfig to TOML string.""" lines: list[str] = [] for section_name in ("default", "agent", "reflector", "skill_manager"): cfg = getattr(config, section_name) if cfg is None: continue lines.append(f"[{section_name}]") d = cfg.to_dict() for key, value in d.items(): if key == "extra_params": # Inline table for extra_params inner = ", ".join(f"{k} = {_toml_value(v)}" for k, v in value.items()) lines.append(f"extra_params = {{ {inner} }}") else: lines.append(f"{key} = {_toml_value(value)}") lines.append("") return "\n".join(lines) def _toml_value(v: Any) -> str: """Format a Python value as a TOML literal.""" if isinstance(v, str): return f'"{v}"' if isinstance(v, bool): return "true" if v else "false" if isinstance(v, float): return str(v) if isinstance(v, int): return str(v) return repr(v) def save_config(config: ACEModelConfig, directory: str | Path = ".") -> Path: """Write ace.toml to *directory*.""" path = Path(directory) / CONFIG_FILENAME path.write_text(_to_toml(config), encoding="utf-8") logger.info("Saved config to %s", path) return path def load_config(directory: str | Path = ".") -> ACEModelConfig: """Load ace.toml from *directory*. Raises: FileNotFoundError: If ace.toml does not exist. """ path = Path(directory) / CONFIG_FILENAME if not path.exists(): raise FileNotFoundError( f"No {CONFIG_FILENAME} found in {Path(directory).resolve()}. " "Run `ace setup` to create one." ) data = tomllib.loads(path.read_text(encoding="utf-8")) return ACEModelConfig.from_dict(data) def find_config(start: str | Path = ".") -> Path | None: """Walk up from *start* looking for ace.toml. Return path or None.""" current = Path(start).resolve() for parent in [current, *current.parents]: candidate = parent / CONFIG_FILENAME if candidate.exists(): return candidate return None # --------------------------------------------------------------------------- # .env helpers # --------------------------------------------------------------------------- def load_dotenv() -> bool: """Load .env if python-dotenv is installed. Return True if loaded.""" try: from dotenv import load_dotenv as _load return _load() except ImportError: return False def save_env_var(key: str, value: str, directory: str | Path = ".") -> None: """Append or update a key in .env file.""" path = Path(directory) / ENV_FILENAME lines: list[str] = [] found = False if path.exists(): for line in path.read_text(encoding="utf-8").splitlines(): if line.startswith(f"{key}="): lines.append(f'{key}="{value}"') found = True else: lines.append(line) if not found: lines.append(f'{key}="{value}"') path.write_text("\n".join(lines) + "\n", encoding="utf-8")