"""Suite configuration loaded from ``skill-eval.toml`` or ``[tool.skill_eval]``. All on-disk paths in the config are resolved to absolute paths against the config file's directory (``root``) at load time. When no config file is found, the prototype's conventional layout is used as the default so existing ``/evals/`` suites work without a config file. """ from pathlib import Path from typing import Any, Literal import tomllib from pydantic import BaseModel, Field from skill_eval_runner.pricing import DEFAULT_PRICING CONFIG_FILENAME = "skill-eval.toml" PYPROJECT = "pyproject.toml" class ArtifactConfig(BaseModel): """How the agent's gradeable artifact is captured.""" mode: Literal["tool", "final_message"] = "tool" tool_name: str = "write_report" tool_description: str = ( "Write the completed report. Call this once with the full content." ) filename: str = "response.md" class ModelsConfig(BaseModel): """Preset model names. ``small`` (iterate), ``large`` (validate), and ``default`` (run/grade) pick the *agent* model per command. ``grader`` optionally pins the grader model independently of the agent — e.g. a cheap agent (Haiku) with a reliable grader (Sonnet). When unset, the grader uses whichever agent model the command resolved. """ small: str | None = None large: str | None = None default: str | None = None grader: str | None = None class PromptsConfig(BaseModel): """Optional system-prompt template overrides. Each template may contain a ``{skill}`` slot (replaced with ``SKILL.md`` for ``with_skill`` runs) and an ``{artifact}`` slot (replaced with the artifact instructions). Substitution is literal string replacement, so skill text containing braces is safe. """ with_skill: str | None = None without_skill: str | None = None class EvalSuiteConfig(BaseModel): """Resolved configuration for one eval suite. All paths are absolute.""" root: Path skill_file: Path suite: Path file_root: Path workspace: Path artifact: ArtifactConfig = Field(default_factory=ArtifactConfig) models: ModelsConfig = Field(default_factory=ModelsConfig) prompts: PromptsConfig = Field(default_factory=PromptsConfig) pricing: dict[str, dict[str, float]] = Field( default_factory=lambda: dict(DEFAULT_PRICING) ) def _read_table(start: Path) -> tuple[dict[str, Any], Path]: """Find the config table and its directory, walking up from ``start``. Returns the raw ``[skill_eval]`` table (empty if none found) and the ``root`` directory the relative paths resolve against. """ current = start.resolve() if current.is_file(): current = current.parent for directory in (current, *current.parents): toml_path = directory / CONFIG_FILENAME if toml_path.exists(): data = tomllib.loads(toml_path.read_text()) return data.get("skill_eval", {}), directory pyproject = directory / PYPROJECT if pyproject.exists(): data = tomllib.loads(pyproject.read_text()) table = data.get("tool", {}).get("skill_eval") if table is not None: return table, directory return {}, current def load_config(start: Path | None = None) -> EvalSuiteConfig: """Load the suite config, discovering ``skill-eval.toml`` near ``start`` (cwd).""" table, root = _read_table(start or Path.cwd()) def resolve(value: str | None, default: str) -> Path: return (root / (value if value is not None else default)).resolve() pricing = dict(DEFAULT_PRICING) pricing.update(table.get("pricing", {})) return EvalSuiteConfig( root=root, skill_file=resolve(table.get("skill_file"), "SKILL.md"), suite=resolve(table.get("suite"), "evals/evals.json"), file_root=resolve(table.get("file_root"), "."), workspace=resolve(table.get("workspace"), "evals/workspace"), artifact=ArtifactConfig.model_validate(table.get("artifact", {})), models=ModelsConfig.model_validate(table.get("models", {})), prompts=PromptsConfig.model_validate(table.get("prompts", {})), pricing=pricing, )