"""Configuration system — hierarchical settings + constants. This module combines: - constants.py (app-wide constants, model registry, system prompt, languages) - settings (hierarchical Settings, MCP server config, policy rules, load_settings) Path layout: - WORKSPACE_ROOT: where the agent's sandboxed filesystem lives - CONFIG_DIR: where settings/agents/hooks/skills are stored (default: .sonicoder/) """ from __future__ import annotations import json import os from dataclasses import dataclass, field from pathlib import Path from typing import Any # ── Re-export constants for backward compatibility ───────────────────── from code.config.constants import ( APP_TITLE, DEFAULT_MAX_TOKENS, DEFAULT_MODEL_KEY, DEFAULT_TEMPERATURE, EXAMPLE_PROMPTS, LANGUAGE_MAP, LANGUAGE_OPTIONS, MODEL_CONFIGS, MODEL_ID, MODEL_URL, SYSTEM_PROMPT, ) # ── Path Constants ───────────────────────────────────────────────────── WORKSPACE_ROOT = Path( os.environ.get( "SONICODER_WORKSPACE", os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "workspace")), ) ) CONFIG_DIR = Path( os.environ.get( "SONICODER_CONFIG", os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".sonicoder")), ) ) # ── Runtime Defaults (override constants) ────────────────────────────── DEFAULT_MAX_ITERATIONS = 12 AGENT_LOOP_TIMEOUT = 300 # seconds BASH_TIMEOUT = 120 PYTHON_TIMEOUT = 30 # ── Settings Dataclasses ─────────────────────────────────────────────── @dataclass class ModelConfig: """Configuration for a single model.""" name: str model_id: str model_type: str # "text" | "vlm" size_gb: float description: str dtype: str = "float16" trust_remote_code: bool = True device: str = "auto" @dataclass class MCPServerConfig: """Configuration for an MCP server.""" name: str transport: str # "stdio" | "sse" command: str | None = None args: list[str] = field(default_factory=list) env: dict[str, str] = field(default_factory=dict) url: str | None = None enabled: bool = True @dataclass class PolicyRule: """A policy rule for tool approval.""" tool_name: str # supports "*" wildcard args_pattern: str | None = None approval: str = "ask" # "allow" | "ask" | "deny" priority: int = 0 description: str = "" @dataclass class Settings: """Hierarchical settings — cascades from global → project → local.""" # Model default_model: str = DEFAULT_MODEL_KEY temperature: float = DEFAULT_TEMPERATURE max_tokens: int = DEFAULT_MAX_TOKENS # Agent max_iterations: int = DEFAULT_MAX_ITERATIONS agent_timeout: int = AGENT_LOOP_TIMEOUT # Tools enabled_tools: list[str] = field(default_factory=lambda: ["*"]) disabled_tools: list[str] = field(default_factory=list) # Safety sandbox_enabled: bool = True bash_timeout: int = BASH_TIMEOUT python_timeout: int = PYTHON_TIMEOUT # MCP mcp_servers: list[MCPServerConfig] = field(default_factory=list) # Policy policy_rules: list[PolicyRule] = field(default_factory=list) # Context max_context_tokens: int = 32768 auto_compact: bool = True @classmethod def from_dict(cls, data: dict[str, Any]) -> Settings: """Create settings from a dictionary, ignoring unknown fields.""" known_fields = {f.name for f in cls.__dataclass_fields__.values()} filtered = {k: v for k, v in data.items() if k in known_fields} return cls(**filtered) def merge(self, other: Settings) -> Settings: """Merge another settings object (other takes precedence for non-default values).""" result = Settings( default_model=other.default_model or self.default_model, temperature=( other.temperature if other.temperature != DEFAULT_TEMPERATURE else self.temperature ), max_tokens=( other.max_tokens if other.max_tokens != DEFAULT_MAX_TOKENS else self.max_tokens ), max_iterations=( other.max_iterations if other.max_iterations != DEFAULT_MAX_ITERATIONS else self.max_iterations ), agent_timeout=( other.agent_timeout if other.agent_timeout != AGENT_LOOP_TIMEOUT else self.agent_timeout ), sandbox_enabled=other.sandbox_enabled, bash_timeout=other.bash_timeout, python_timeout=other.python_timeout, auto_compact=other.auto_compact, ) # Merge lists result.enabled_tools = ( other.enabled_tools if other.enabled_tools != ["*"] else self.enabled_tools ) result.disabled_tools = list(set(self.disabled_tools + other.disabled_tools)) result.mcp_servers = self.mcp_servers + other.mcp_servers result.policy_rules = other.policy_rules + self.policy_rules return result # ── Public Helpers ───────────────────────────────────────────────────── def get_available_models() -> dict[str, dict[str, Any]]: """Return all registered model configs (from constants.MODEL_CONFIGS).""" return MODEL_CONFIGS.copy() def get_model_config(model_key: str) -> dict[str, Any] | None: """Get a model config by key.""" return MODEL_CONFIGS.get(model_key) def load_settings(project_dir: Path | None = None) -> Settings: """Load settings with hierarchical cascading. Priority (highest → lowest): 1. Environment variables 2. .sonicoder/settings.local.json (local overrides, gitignored) 3. .sonicoder/settings.json (project settings) 4. Defaults """ settings = Settings() search_dirs = [Path.cwd()] if project_dir: search_dirs.insert(0, project_dir) # Load project-level settings for d in search_dirs: local_path = d / CONFIG_DIR / "settings.local.json" proj_path = d / CONFIG_DIR / "settings.json" for path in [local_path, proj_path]: if path.exists(): try: data = json.loads(path.read_text(encoding="utf-8")) # Parse MCP server configs if "mcp_servers" in data: data["mcp_servers"] = [ MCPServerConfig(**s) for s in data["mcp_servers"] ] if "policy_rules" in data: data["policy_rules"] = [ PolicyRule(**r) for r in data["policy_rules"] ] settings = settings.merge(Settings.from_dict(data)) except (json.JSONDecodeError, TypeError) as e: print(f"[config] Warning: Failed to parse {path}: {e}") # Environment variable overrides env_model = os.environ.get("SONICODER_MODEL") if env_model and env_model in MODEL_CONFIGS: settings.default_model = env_model env_temp = os.environ.get("SONICODER_TEMPERATURE") if env_temp: try: settings.temperature = float(env_temp) except ValueError: pass return settings __all__ = [ # Constants "APP_TITLE", "DEFAULT_MAX_TOKENS", "DEFAULT_MODEL_KEY", "DEFAULT_TEMPERATURE", "EXAMPLE_PROMPTS", "LANGUAGE_MAP", "LANGUAGE_OPTIONS", "MODEL_CONFIGS", "MODEL_ID", "MODEL_URL", "SYSTEM_PROMPT", # Path constants "WORKSPACE_ROOT", "CONFIG_DIR", # Runtime defaults "DEFAULT_MAX_ITERATIONS", "AGENT_LOOP_TIMEOUT", "BASH_TIMEOUT", "PYTHON_TIMEOUT", # Dataclasses "ModelConfig", "MCPServerConfig", "PolicyRule", "Settings", # Helpers "get_available_models", "get_model_config", "load_settings", ]