"""Pydantic configuration models mapping to config/config.yaml.""" import os from enum import StrEnum from pathlib import Path from typing import Any import yaml from pydantic import BaseModel, Field, model_validator class AgentMode(StrEnum): """Runtime agent mode controlling permission behavior.""" NORMAL = "normal" PLAN = "plan" AUTO = "auto" 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") thinking: bool = Field( default=True, description="Enable model thinking/reasoning mode (disable to reduce reasoning-only loops)", ) extra_body: dict[str, Any] = Field( default_factory=dict, description="Extra parameters merged into the API request body (model-specific)", ) 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 FileCacheConfig(BaseModel): """File cache configuration.""" enabled: bool = Field(default=True, description="Enable file content caching") max_entries: int = Field(default=128, description="Maximum cached file entries (LRU eviction)") 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") cache: FileCacheConfig = Field(default_factory=FileCacheConfig, description="File cache settings") 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 SkillsConfig(BaseModel): """Skills system configuration.""" enabled: bool = Field(default=True, description="Enable skills system") directories: list[Path] = Field( default_factory=lambda: [Path(".sneakycode/skills")], description="Directories to scan for skill markdown files", ) class DebugConfig(BaseModel): """Debug logging configuration.""" enabled: bool = Field(default=False, description="Enable debug logging") log_dir: Path = Field(default=Path(".sneakycode/logs"), description="Debug log directory") max_files: int = Field(default=10, description="Max debug log files to retain") 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) debug: DebugConfig = Field(default_factory=DebugConfig) skills: SkillsConfig = Field(default_factory=SkillsConfig) @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)