"""Pydantic configuration models mapping to config/config.yaml.""" import os from pathlib import Path import yaml from pydantic import BaseModel, Field, model_validator class LLMConfig(BaseModel): """LLM backend configuration.""" model: str = Field(description="Model name to use") endpoint: str = Field(description="Base URL of the LLM API") api_path: str = Field(default="/v1/chat/completions", description="API endpoint path") temperature: float = Field(default=0.1, description="Sampling temperature") max_tokens: int = Field(default=4096, description="Maximum tokens in LLM response") timeout: int = Field(default=120, description="Request timeout in seconds") max_retries: int = Field(default=3, description="Max retry attempts on transient errors") retry_backoff_base: float = Field(default=1.0, description="Base seconds for exponential backoff") retry_backoff_max: float = Field(default=30.0, description="Maximum backoff seconds") class AgentConfig(BaseModel): """Agent loop configuration.""" max_iterations: int = Field(default=25, description="Maximum tool-call loop iterations") max_conversation_tokens: int = Field( default=32000, description="Token budget for conversation history" ) workspace_root: Path = Field( default=Path("."), description="Root directory for file operations" ) truncation_keep_recent: int = Field( default=10, description="Number of recent messages to preserve during truncation" ) truncation_threshold: float = Field( default=0.85, description="Token budget fraction that triggers truncation" ) class PermissionsConfig(BaseModel): """Tool permission tiers.""" auto_approve: list[str] = Field(default_factory=list, description="Auto-approved tools") prompt_user: list[str] = Field(default_factory=list, description="Tools requiring confirmation") deny: list[str] = Field(default_factory=list, description="Tools that are blocked entirely") class ShellToolConfig(BaseModel): """Shell tool restrictions.""" allowed_commands: list[str] = Field(default_factory=list, description="Allowed shell commands") denied_commands: list[str] = Field(default_factory=list, description="Blocked shell commands") max_output_bytes: int = Field(default=65536, description="Max output capture size in bytes") class FilesystemToolConfig(BaseModel): """Filesystem tool limits.""" max_file_size_bytes: int = Field(default=1_048_576, description="Max file size for read/write") binary_detection: bool = Field(default=True, description="Detect and reject binary files") class ToolsConfig(BaseModel): """Aggregate tool configuration.""" shell: ShellToolConfig = Field(default_factory=ShellToolConfig) filesystem: FilesystemToolConfig = Field(default_factory=FilesystemToolConfig) class SessionConfig(BaseModel): """Session persistence configuration.""" session_dir: Path = Field( default=Path(".sneakycode/sessions"), description="Directory for session files" ) auto_save: bool = Field(default=True, description="Auto-save session after each turn") max_session_age_hours: int = Field( default=72, description="Max age in hours before session files are cleaned up" ) offer_resume: bool = Field(default=True, description="Offer to resume previous sessions on startup") class DisplayConfig(BaseModel): """Terminal display preferences.""" show_tool_calls: bool = Field(default=True, description="Show tool call details in output") show_token_usage: bool = Field(default=True, description="Show token usage stats") stream_output: bool = Field(default=True, description="Stream LLM output to terminal") class AppConfig(BaseModel): """Top-level application configuration composing all sub-configs.""" llm: LLMConfig agent: AgentConfig = Field(default_factory=AgentConfig) permissions: PermissionsConfig = Field(default_factory=PermissionsConfig) tools: ToolsConfig = Field(default_factory=ToolsConfig) display: DisplayConfig = Field(default_factory=DisplayConfig) session: SessionConfig = Field(default_factory=SessionConfig) @model_validator(mode="after") def resolve_workspace_root(self) -> "AppConfig": """Resolve workspace_root to an absolute path.""" self.agent.workspace_root = self.agent.workspace_root.resolve() return self # Default config file location relative to project root _DEFAULT_CONFIG_PATH = Path("config/config.yaml") def load_config(config_path: Path | None = None) -> AppConfig: """Load and validate application config from YAML. Resolution order: 1. Explicit config_path argument 2. SNEAKYCODE_CONFIG environment variable 3. config/config.yaml (default) Args: config_path: Optional explicit path to config file. Returns: Validated AppConfig instance. Raises: FileNotFoundError: If the resolved config file does not exist. ValueError: If the config file is invalid YAML or fails validation. """ if config_path is None: env_path = os.environ.get("SNEAKYCODE_CONFIG") if env_path: config_path = Path(env_path) else: config_path = _DEFAULT_CONFIG_PATH config_path = config_path.resolve() if not config_path.exists(): raise FileNotFoundError(f"Config file not found: {config_path}") with open(config_path) as f: raw = yaml.safe_load(f) if not isinstance(raw, dict): raise ValueError(f"Config file must contain a YAML mapping, got {type(raw).__name__}") return AppConfig(**raw)