diff --git a/agents-core/vision_agents/cli/agent/config.py b/agents-core/vision_agents/cli/agent/config.py index e36def15..ab212bc5 100644 --- a/agents-core/vision_agents/cli/agent/config.py +++ b/agents-core/vision_agents/cli/agent/config.py @@ -1,86 +1,18 @@ -"""Locate and parse the project's ``pyproject.toml`` agent config.""" +"""Resolve the agent entrypoint from settings or the ``--entrypoint`` flag.""" -import tomllib from pathlib import Path from vision_agents.cli.agent.models import ResolvedEntrypoint from vision_agents.cli.errors import CliError +from vision_agents.config import ( + TOOL_TABLE, + AgentSettings, + Settings, + load_settings, + parse_entrypoint, +) -CONFIG_FILENAME = "pyproject.toml" -CONFIG_KEY = "tool.vision-agents.agent.entrypoint" - - -def parse_entrypoint(spec: object) -> tuple[str, str]: - """Parse a gunicorn-style ``module:attribute`` spec. - - Raises: - ValueError: if ``spec`` is not a non-empty ``module:attribute`` string. - """ - if not isinstance(spec, str) or ":" not in spec: - raise ValueError( - f"entrypoint {spec!r} must be in 'module:attribute' form " - "(e.g. 'agent:runner')" - ) - if spec.count(":") != 1: - raise ValueError( - f"entrypoint {spec!r} is malformed; expected exactly one ':' " - "separator in 'module:attribute'" - ) - module, _, attribute = spec.partition(":") - if not module or not attribute: - raise ValueError( - f"entrypoint {spec!r} is malformed; expected 'module:attribute'" - ) - if module.endswith(".py"): - raise ValueError( - f"entrypoint {spec!r} looks like a file path; entrypoint is a " - f"Python import name, not a filename — try {module[:-3]!r}:{attribute!r} " - f"(i.e. '{module[:-3]}:{attribute}')" - ) - return module, attribute - - -def find_config(start: Path) -> Path: - """Walk up from ``start`` looking for ``pyproject.toml``. - - Raises: - CliError: if no ``pyproject.toml`` is found. - """ - for directory in (start, *start.parents): - candidate = directory / CONFIG_FILENAME - if candidate.is_file(): - return candidate - raise CliError( - "Could not find pyproject.toml; you may not be in a Vision Agents project." - ) - - -def load_agent_config(config_path: Path) -> tuple[str, str]: - """Parse ``[tool.vision-agents.agent]`` from ``pyproject.toml``. - - Returns ``(module, attribute)``. Strict on required fields and types; - unknown keys are ignored for forward compatibility. - """ - try: - with config_path.open("rb") as handle: - data = tomllib.load(handle) - except OSError as err: - raise CliError(f"failed to read {config_path}: {err}") from err - except tomllib.TOMLDecodeError as err: - raise CliError(f"failed to parse {config_path}: {err}") from err - - tool = data.get("tool") - va = tool.get("vision-agents") if isinstance(tool, dict) else None - section = va.get("agent") if isinstance(va, dict) else None - if not isinstance(section, dict): - raise CliError( - f"{config_path} is missing required [tool.vision-agents.agent] section" - ) - - try: - return parse_entrypoint(section.get("entrypoint")) - except ValueError as err: - raise CliError(f"{config_path}: {err}") from err +CONFIG_KEY = f"{TOOL_TABLE}.agent.entrypoint" def resolve_entrypoint(cwd: Path, override: str | None) -> ResolvedEntrypoint: @@ -100,11 +32,31 @@ def resolve_entrypoint(cwd: Path, override: str | None) -> ResolvedEntrypoint: config_path=None, ) - config_path = find_config(cwd) - module, attribute = load_agent_config(config_path) + settings = _load_or_raise(cwd) + agent = _require_agent(settings) return ResolvedEntrypoint( - project_root=config_path.parent.resolve(), - module=module, - attribute=attribute, - config_path=config_path, + project_root=settings.project_root, + module=agent.module, + attribute=agent.attribute, + config_path=settings.pyproject_path, ) + + +def _load_or_raise(cwd: Path) -> Settings: + try: + return load_settings(cwd) + except FileNotFoundError as err: + raise CliError( + "Could not find pyproject.toml; you may not be in a Vision Agents project." + ) from err + except ValueError as err: + raise CliError(str(err)) from err + + +def _require_agent(settings: Settings) -> AgentSettings: + if settings.agent is None: + raise CliError( + f"{settings.pyproject_path} is missing required " + f"[{TOOL_TABLE}.agent] section" + ) + return settings.agent diff --git a/agents-core/vision_agents/config.py b/agents-core/vision_agents/config.py new file mode 100644 index 00000000..44fe07e9 --- /dev/null +++ b/agents-core/vision_agents/config.py @@ -0,0 +1,131 @@ +"""Project-level configuration for Vision Agents. + +Reads ``[tool.vision-agents.*]`` from the nearest ``pyproject.toml``. +""" + +import tomllib +from dataclasses import dataclass, field +from pathlib import Path +from typing import cast + +PYPROJECT_FILENAME = "pyproject.toml" +TOOL_TABLE = "tool.vision-agents" + + +@dataclass(frozen=True) +class AgentSettings: + """``[tool.vision-agents.agent]`` — how to invoke the project's ``Runner``.""" + + module: str # "agent" in "agent:runner" + attribute: str # "runner" in "agent:runner" + + @property + def entrypoint(self) -> str: + """Round-trip ``module:attribute`` representation.""" + return f"{self.module}:{self.attribute}" + + +@dataclass(frozen=True) +class Settings: + """The resolved Vision Agents project configuration. + + Attributes: + project_root: Directory containing the discovered ``pyproject.toml``. + pyproject_path: Path to the discovered ``pyproject.toml`` itself. + agent: Parsed ``[tool.vision-agents.agent]`` table or ``None``. + raw: Full ``[tool.vision-agents]`` dict — escape hatch for plugin + or user code reading custom subsections we don't model yet. + """ + + project_root: Path + pyproject_path: Path + agent: AgentSettings | None = None + raw: dict[str, object] = field(default_factory=dict) + + +def find_pyproject(start: Path) -> Path: + """Walk up from ``start`` looking for ``pyproject.toml``. + + Raises: + FileNotFoundError: if no ``pyproject.toml`` is found anywhere on the + way up. Callers should reformat to a domain-appropriate error. + """ + for directory in (start, *start.parents): + candidate = directory / PYPROJECT_FILENAME + if candidate.is_file(): + return candidate + raise FileNotFoundError(f"no {PYPROJECT_FILENAME} found starting from {start}") + + +def parse_entrypoint(spec: object) -> tuple[str, str]: + """Parse a gunicorn-style ``module:attribute`` spec. + + Raises: + ValueError: if ``spec`` is not a non-empty ``module:attribute`` string. + """ + if not isinstance(spec, str) or ":" not in spec: + raise ValueError( + f"entrypoint {spec!r} must be in 'module:attribute' form " + "(e.g. 'agent:runner')" + ) + if spec.count(":") != 1: + raise ValueError( + f"entrypoint {spec!r} is malformed; expected exactly one ':' " + "separator in 'module:attribute'" + ) + module, _, attribute = spec.partition(":") + if not module or not attribute: + raise ValueError( + f"entrypoint {spec!r} is malformed; expected 'module:attribute'" + ) + if module.endswith(".py"): + raise ValueError( + f"entrypoint {spec!r} looks like a file path; entrypoint is a " + f"Python import name, not a filename — try {module[:-3]!r}:{attribute!r} " + f"(i.e. '{module[:-3]}:{attribute}')" + ) + return module, attribute + + +def load_settings(cwd: Path | None = None) -> Settings: + """Load Vision Agents settings from the nearest ``pyproject.toml``. + + Walks up from ``cwd`` (default: :func:`Path.cwd`) to find the project's + ``pyproject.toml``, then parses ``[tool.vision-agents.*]`` sections into + a :class:`Settings` instance. + + Args: + cwd: Directory to start the upward search from. Defaults to the + current working directory. + + Raises: + FileNotFoundError: when no ``pyproject.toml`` is found. + ValueError: when ``[tool.vision-agents.*]`` is malformed (wrong + type for a known field, malformed entrypoint, etc.). + """ + start = cwd if cwd is not None else Path.cwd() + pyproject_path = find_pyproject(start) + + with pyproject_path.open("rb") as handle: + data = tomllib.load(handle) + + tool = data.get("tool") + raw = tool.get("vision-agents") if isinstance(tool, dict) else None + if not isinstance(raw, dict): + raw = {} + + return Settings( + project_root=pyproject_path.parent.resolve(), + pyproject_path=pyproject_path, + agent=_parse_agent(raw.get("agent")), + raw=cast(dict[str, object], raw), + ) + + +def _parse_agent(section: object) -> AgentSettings | None: + if section is None: + return None + if not isinstance(section, dict): + raise ValueError(f"[{TOOL_TABLE}.agent] must be a table") + module, attribute = parse_entrypoint(section.get("entrypoint")) + return AgentSettings(module=module, attribute=attribute)