Skip to content
Merged
2 changes: 1 addition & 1 deletion src/agent_env/a2a_agent/a2a_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,7 @@ def accept_toml(cls, data: dict[str, Any], ctx: AuthoringContext) -> dict[str, A
kinds, described = cls.toml_metadata[name]
if isinstance(value, bool) or not isinstance(value, kinds):
values.append(f"metadata.{name} must be {described}, not {value!r}")
problems += [f"agent.toml: {problem}" for problem in values]
problems += [ctx.config_problem(problem) for problem in values]
if problems:
ctx.refuse(problems)
return fields
Expand Down
95 changes: 89 additions & 6 deletions src/agent_env/bundle/authoring.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@
from agent_env.artifact.artifact import Artifact
from agent_env.entity_refs import EntityKind, parse_toml_ref
from agent_env.env.env import Env
from agent_env.env.envs._deployment import provider_refusal
from agent_env.providers.env_providers.constants import GATEWAY_SERVICE_NAMES
from agent_env.providers.env_providers.env_gateway_provider import EnvironmentGatewayProvider
from agent_env.providers.env_providers.env_provider import _env_provider_class
from agent_env.utils.card_naming import card_names_in_files

from ._fs import os_reason, relative, show, with_article
from .parse import CONFIG_FILES, LEAVINGS, NAMED_BY, Bundle, BundleEntry, BundleError
Expand All @@ -20,7 +25,13 @@
E = TypeVar("E", bound=Env)

_BRING_IT_IN = "copy what it points to into the bundle, or put it in a store and refer to it by id"
_TOML_TYPES = {str: "a string", dict: "a table"}
_TOML_TYPES = {str: "a string", dict: "a table", list: "a list"}


def default_dockerfile(key: str) -> str:
"""The Dockerfile an image key left out builds from the entry's folder: ``Dockerfile`` for ``image``, and
``Dockerfile.<role>`` for ``<role>_image``, as a website's ``backend_image`` and ``frontend_image``."""
return "Dockerfile" if key == "image" else f"Dockerfile.{key.removesuffix('_image')}"


@dataclass(frozen=True)
Expand Down Expand Up @@ -50,6 +61,19 @@ def files(self) -> dict[str, Path]:
def file(self) -> tuple[str, Path]:
return entry_file(self.bundle, self.entry)

def card_names(self, image_key: str) -> list[str] | None:
"""The names ``@environment_card(name=...)`` gives in the source of the image ``image_key`` builds from
this folder: the ``.py`` files of its build context, those in its Dockerfile's folder first. None when the
key names an image rather than building one."""
value = self.entry.config.get(image_key) if isinstance(self.entry.config, dict) else None
if value is None:
dockerfile = default_dockerfile(image_key)
elif isinstance(value, dict) and value.keys() == {"dockerfile"} and isinstance(value["dockerfile"], str):
dockerfile = value["dockerfile"]
else:
return None
return card_names_in_files(build_context_files(self.bundle, self.entry), dockerfile)

def accept(self, data: dict, /, **takes: type) -> dict:
"""The keys of ``data`` a type takes, each checked against the type given; any other key but
``type`` and ``id`` is refused."""
Expand All @@ -61,20 +85,79 @@ def accept(self, data: dict, /, **takes: type) -> dict:
def accepted(self, data: dict, /, **takes: type) -> tuple[dict, list[str]]:
"""``accept`` without refusing: the keys of ``data`` a type takes and of the type given, and the
problems with the rest, for a type that checks more before it refuses."""
config = CONFIG_FILES[self.entry.kind]
problems = []
unknown = sorted(set(data) - {"type", "id", *takes})
if unknown:
names = [*takes, "type", "id"]
problems.append(f"{config}: unknown key{'s' * (len(unknown) > 1)} {', '.join(map(repr, unknown))}; "
f"{with_article(f'{self.entry.type} {self.entry.kind.store}')} takes "
f"{', '.join(names[:-1])} and {names[-1]}")
problems.append(self.config_problem(
f"unknown key{'s' * (len(unknown) > 1)} {', '.join(map(repr, unknown))}; "
f"{with_article(f'{self.entry.type} {self.entry.kind.store}')} takes "
f"{', '.join(names[:-1])} and {names[-1]}"))
for key, kind in takes.items():
if key in data and not isinstance(data[key], kind):
problems.append(f"{config}: {key} must be {_TOML_TYPES.get(kind, kind.__name__)}, not {data[key]!r}")
problems.append(self.config_problem(
f"{key} must be {_TOML_TYPES.get(kind, kind.__name__)}, not {data[key]!r}"))
fields = {key: data[key] for key, kind in takes.items() if key in data and isinstance(data[key], kind)}
return fields, problems

def accept_env(self, data: dict, env_class: type[Env], *, named_by: str | None = None) -> dict:
"""The keys of ``data``, an env.toml, ``env_class`` takes, checked the way ``accepted_env`` checks
them; every problem is refused."""
fields, problems = self.accepted_env(data, env_class, named_by=named_by)
if problems:
self.refuse(problems)
return fields

def accepted_env(self, data: dict, env_class: type[Env], *,
named_by: str | None = None) -> tuple[dict, list[str]]:
"""``accepted`` for an env.toml: the keys ``env_class`` takes, and the problems with them. The fields
carry its env_provider_type, the gateway's when left out, which must name an installed provider that
deploys an env of ``env_class``. With ``named_by``, they carry its environment_name too: the one set,
else the one the ``@environment_card`` in the source of the image that key builds gives, which a
gateway deploy can't take from one of its own containers."""
fields, problems = self.accepted(data, **env_class.toml_keys)
provider_type = fields.setdefault("env_provider_type", EnvironmentGatewayProvider.type)
try:
provider = _env_provider_class(provider_type)
except ValueError as e:
problems.append(self.config_problem(str(e)))
provider = None
if provider is not None and (reason := provider_refusal(provider, env_class)) is not None:
problems.append(self.config_problem(f"env_provider_type {provider_type!r} {reason}"))
if named_by is not None:
name, problem = self._environment_name(data, fields, named_by)
if problem is not None:
problems.append(self.config_problem(problem))
elif name is not None:
fields["environment_name"] = name
if (name in GATEWAY_SERVICE_NAMES and provider is not None
and issubclass(provider, EnvironmentGatewayProvider)):
problems.append(self.config_problem(
f"environment_name {name!r} is one a gateway deploy names its own containers "
f"({', '.join(sorted(GATEWAY_SERVICE_NAMES))}); choose another"))
return fields, problems

def _environment_name(self, data: dict, fields: dict, named_by: str) -> tuple[str | None, str | None]:
"""The env's environment_name, and the problem when there's none: the one set, else the one the source
of the image ``named_by`` builds from this folder declares."""
if "environment_name" in data: # one of the wrong type is a problem accepted() reported
name = fields.get("environment_name")
return name, "environment_name can't be empty" if name == "" else None
names = self.card_names(named_by)
what = "image" if named_by == "image" else named_by.replace("_", " ")
if names is None:
return None, (f"environment_name isn't set, and its {what} isn't built from this folder, so there's no "
"source to read it from; set it")
if len(names) != 1:
found = (f"several environment cards ({', '.join(map(repr, names))})" if names
else "no @environment_card(name=...)")
return None, f"environment_name isn't set, and the source its {what} is built from declares {found}; set it"
return names[0], None

def config_problem(self, message: str) -> str:
"""``message`` as a problem with the entry's toml, ready for ``refuse``."""
return f"{CONFIG_FILES[self.entry.kind]}: {message}"

def refuse(self, problems: list[str]) -> NoReturn:
"""Raise BundleError with ``problems``, each named by the entry's folder."""
where = relative(self.bundle.root, self.entry.path)
Expand Down
10 changes: 4 additions & 6 deletions src/agent_env/bundle/ledger.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@
from agent_env.artifact.registry import canonical_type, get_artifact_registry
from agent_env.artifact.store import ARTIFACTS_COLLECTION
from agent_env.config import get_config
from agent_env.env.env import Env
from agent_env.env.registry import get_env_registry
from agent_env.env.store import ENVS_COLLECTION
from agent_env.eval.store import EVALS_COLLECTION
Expand All @@ -33,7 +32,7 @@

from .authoring import build_context_files, entry_files
from .parse import Bundle, BundleKind
from .plan import Plan, Write, folder_walk, keeps_base_from_toml, unpinned_store_refs
from .plan import Plan, Write, env_writer, folder_walk, unpinned_store_refs
from .resolve import BuiltImage

LEDGER_COLLECTION = "bundle_ledger"
Expand Down Expand Up @@ -200,8 +199,8 @@ def materializing(bundle: Bundle, on_wait: Callable[[], None] | None = None) ->

def _tracked(write: Write) -> bool:
"""Whether the ledger can list everything ``write`` is made from. Not yet for a skill, nor for a type with
a ``from_toml`` of its own, which may read its folder in ways it can't see. An agent is made from its
agent.toml alone: its image is a reference. A built image is made from its build context,
a ``from_toml`` of its own, which may read its folder in ways it can't see. An agent or env is made from its
toml alone: its images and envs are references. A built image is made from its build context,
``build_context_files``; what the build fetches (its base image, packages) isn't an input."""
if isinstance(write.source, BuiltImage):
return True
Expand All @@ -210,8 +209,7 @@ def _tracked(write: Write) -> bool:
if write.kind is BundleKind.ARTIFACT:
return folder_walk(get_artifact_registry().get(_type(write))) is not None
if write.kind is BundleKind.ENV:
cls = get_env_registry().get(_type(write))
return cls is not None and keeps_base_from_toml(cls, Env)
return env_writer(get_env_registry().get(_type(write)))
return True


Expand Down
21 changes: 15 additions & 6 deletions src/agent_env/bundle/materialize.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from agent_env.artifact.artifacts.docker_image import DockerImageArtifact
from agent_env.artifact.registry import canonical_type, get_artifact_registry
from agent_env.entity_refs import EntityRef, RefRole, ref_sites
from agent_env.env.registry import get_env_registry
from agent_env.eval import Eval, EvalTask
from agent_env.store.ids import image_repository
from agent_env.store.routing import namespace_routing_enabled
Expand All @@ -38,7 +39,7 @@
from .authoring import AuthoringContext, build_context_files
from .ledger import Ledger, materializing
from .parse import BundleError, BundleKind
from .plan import Plan, Write, folder_walk, unpinned_store_refs
from .plan import Plan, Write, env_writer, folder_walk, unpinned_store_refs
from .resolve import BuiltImage, build_step

@dataclass(frozen=True)
Expand Down Expand Up @@ -184,6 +185,12 @@ def _write_agent(plan: Plan, write: Write) -> int:
AuthoringContext(plan.bundle.bundle, entry)).version


def _write_env(plan: Plan, write: Write) -> int:
entry = write.source.entry
cls = get_env_registry()[entry.type]
return cls.from_toml(_pinned(plan, write, cls.toml_refs), AuthoringContext(plan.bundle.bundle, entry)).version


def _pinned(plan: Plan, write: Write, refs: tuple[EntityRef, ...]) -> Any:
"""A copy of ``write``'s resolved toml with each store ref that names no version pinned to the version
the plan read, which the ledger hashed (``unpinned_store_refs``)."""
Expand All @@ -210,7 +217,8 @@ def _write_eval(plan: Plan, write: Write) -> int:
# The writers this release has, by kind; any other kind is refused before anything is written. Tasks are
# written separately, once every one of them is preflighted, and evals after them, since they name the tasks.
_WRITERS: dict[BundleKind, Callable[[Plan, Write], int]] = {
BundleKind.ARTIFACT: _write_artifact, BundleKind.AGENT: _write_agent, BundleKind.EVAL: _write_eval,
BundleKind.ARTIFACT: _write_artifact, BundleKind.AGENT: _write_agent, BundleKind.ENV: _write_env,
BundleKind.EVAL: _write_eval,
}


Expand Down Expand Up @@ -238,10 +246,11 @@ def _unwritable(write: Write) -> str | None:
return None
if write.kind not in _WRITERS:
return with_article(write.kind.value.removesuffix("s"))
if write.kind is BundleKind.ARTIFACT:
type_ = write.source.entry.type
if folder_walk(get_artifact_registry().get(canonical_type(type_))) is None:
return with_article(f"{type_} artifact")
type_ = write.source.entry.type
if write.kind is BundleKind.ARTIFACT and folder_walk(get_artifact_registry().get(canonical_type(type_))) is None:
return with_article(f"{type_} artifact")
if write.kind is BundleKind.ENV and not env_writer(get_env_registry().get(type_)):
return with_article(f"{type_} env")
return None


Expand Down
Loading
Loading