Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 35 additions & 83 deletions agents-core/vision_agents/cli/agent/config.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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
131 changes: 131 additions & 0 deletions agents-core/vision_agents/config.py
Original file line number Diff line number Diff line change
@@ -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'"
)
Comment on lines +76 to +80

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Trim entrypoint segments before emptiness checks.

"pkg :runner" and "pkg: runner" currently pass validation but produce downstream import errors. Normalize whitespace in parser so invalid forms fail early with config errors.

Proposed fix
-    module, _, attribute = spec.partition(":")
+    module, _, attribute = spec.partition(":")
+    module = module.strip()
+    attribute = attribute.strip()
     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 = {}

Comment on lines +112 to +116

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Raise ValueError when [tool.vision-agents] exists but is not a table.

Current logic silently downgrades invalid schema to {}. That violates the loader’s malformed-schema contract and can hide user config mistakes.

Proposed fix
-    tool = data.get("tool")
-    raw = tool.get("vision-agents") if isinstance(tool, dict) else None
-    if not isinstance(raw, dict):
-        raw = {}
+    tool = data.get("tool")
+    if tool is not None and not isinstance(tool, dict):
+        raise ValueError("[tool] must be a table")
+
+    raw_section = tool.get("vision-agents") if isinstance(tool, dict) else None
+    if raw_section is not None and not isinstance(raw_section, dict):
+        raise ValueError(f"[{TOOL_TABLE}] must be a table")
+    raw = raw_section if isinstance(raw_section, dict) else {}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
tool = data.get("tool")
raw = tool.get("vision-agents") if isinstance(tool, dict) else None
if not isinstance(raw, dict):
raw = {}
tool = data.get("tool")
if tool is not None and not isinstance(tool, dict):
raise ValueError("[tool] must be a table")
raw_section = tool.get("vision-agents") if isinstance(tool, dict) else None
if raw_section is not None and not isinstance(raw_section, dict):
raise ValueError(f"[{TOOL_TABLE}] must be a table")
raw = raw_section if isinstance(raw_section, dict) else {}

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)