Skip to content
Merged
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
10 changes: 10 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,16 @@ Out-of-tree code plugs in either through `config.toml`, as a `module:Class` poin
`A2AAgent.validate()` deploys `mail` (`A2AAgent.VALIDATION_ENV_ID`) with namespace routing on, first writing the
example (its writes and infra, no run) when the store has no env of that id: on a remote sandbox its image is
context-only and built in the VM, so a service like the hub's worker needs no Docker.
- A bundle names the plugin packages it needs in an optional `bundle.toml` at its root (`requires = ["<pip
requirement>", ...]`, read by `bundle/parse.py`, checked by `bundle/requires.py`). Resolving a bundle refuses one
whose requirements this environment doesn't meet (`UnmetRequirements`), naming the `agent-env plugin add` command
that installs them (a requirement on agent-env itself says to upgrade it instead); in a terminal `agent-env run` and
`bundle register` offer to run it, then run the command again in a new process. A run or registration of a writable folder run by its path that has no `bundle.toml` creates one,
requiring at least the installed version of each plugin distribution whose type the bundle uses, never over a file
that appeared meanwhile. An existing one is never edited: the run says what to add, so `requires = []` keeps a
bundle requiring nothing. A type only configuration registers gets a hint naming the installed package its class
comes from, never a requirement. `bundle init
--from-task` writes the same file.
- Environment providers have no table: an installed package registers one as an `agent_env.env_providers`
entry point named its `type`, which records carry as `env_provider_type`.
- `[plugins.<distribution name>]` is reserved for an installed plugin's own settings, read with
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ dependencies = [
"httpx>=0.27.0",
"litellm>=1.96.0,<1.97",
"mcp>=1.25,<2", # same floor as env/gateway/requirements.txt and agentenv-protocol; 2.0.0 removed mcp.server.fastmcp, imported at module scope by env/gateway
"packaging>=22", # evaluates a plugin's requirement markers with no extra
"packaging>=22", # parses a bundle.toml's requires; evaluates a plugin's requirement markers with no extra
"a2a-sdk==0.3.26",
"pydantic>=2.12", # model_validate(extra=...) reads agent responses tolerantly
"pymongo>=4.7",
Expand Down
25 changes: 11 additions & 14 deletions src/agent_env/bundle/_import_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
from agent_env.utils.docker.lock import IN_IMAGE, Locked
from agent_env.utils.docker.prune import Pruned

from .requires import Use

# The file an import records what it wrote in, and from where, for publishing the folder back.
PROVENANCE = "provenance.json"

Expand Down Expand Up @@ -50,16 +52,6 @@ class Dropped:
why: str


@dataclass(frozen=True)
class Need:
"""Something a task uses that core doesn't ship, and what registers it here."""

where: str
what: str # step type, env type or artifact type
name: str
by: str # the installed distribution that registers it, or "configuration" for a config file's impls


@dataclass(frozen=True)
class Written:
"""An entity written into the folder."""
Expand Down Expand Up @@ -124,7 +116,7 @@ class BundleImport:
pinned: tuple[Pinned, ...]
floating: tuple[Floating, ...]
dropped: tuple[Dropped, ...]
needs: tuple[Need, ...]
needs: tuple[Use, ...] # what the task uses that core doesn't ship
undeclared: tuple[str, ...] # the steps whose type declares no references, so any they make float
duplicates: tuple[str, ...] # the eval's entries that named a task it already named
downloaded: int # bytes, of what the store said its objects' sizes are
Expand All @@ -134,7 +126,7 @@ def report(self) -> list[tuple[str, list[str]]]:
secret and scoped field was treated, as titled lists of lines; an empty section is left out."""
needed: dict[tuple[str, str, str], list[str]] = {}
for need in self.needs:
needed.setdefault((need.by, need.name, need.what), []).append(need.where)
needed.setdefault((_by(need), need.name, need.what), []).append(need.where)
return _sections([
("Still needs the store", [f"{pin.kind.value} {pin.id} v{pin.version}: {pin.where}: {pin.why}"
for pin in self.pinned]),
Expand Down Expand Up @@ -244,9 +236,14 @@ def _listed(items: list[str]) -> str:
]


def _by(need: Use) -> str:
"""What registers ``need`` here, as the report names it: the installed distribution, or configuration."""
return need.provider.distribution or "configuration"


def _needs_lines(imported: BundleImport) -> list[str]:
plugins = sorted({need.by for need in imported.needs if need.by != "configuration"})
configured = any(need.by == "configuration" for need in imported.needs)
plugins = sorted({need.provider.distribution for need in imported.needs if need.provider.distribution})
configured = any(need.provider.distribution is None for need in imported.needs)
lines = [f"- The plugins it uses: `{'`, `'.join(plugins)}`."] if plugins else []
if configured:
lines.append("- The configuration that registers what *Needs beyond core* says configuration registers.")
Expand Down
50 changes: 12 additions & 38 deletions src/agent_env/bundle/importer.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
from __future__ import annotations

import contextlib
import importlib
import json
import os
import shutil
Expand All @@ -19,7 +18,7 @@
from agent_env.artifact.artifact import Artifact
from agent_env.artifact.artifacts.docker_image import DockerImageArtifact
from agent_env.artifact.artifacts.skill import SkillArtifact
from agent_env.artifact.registry import ARTIFACT_REGISTRY, canonical_type, get_artifact_registry
from agent_env.artifact.registry import canonical_type, get_artifact_registry
from agent_env.bundle import _toml
from agent_env.bundle._fs import fold, show
from agent_env.bundle._images import ImageJob, context_groups, write_images
Expand All @@ -30,7 +29,6 @@
Floating,
ImportedEval,
ImportedTask,
Need,
Pinned,
Pulled,
Written,
Expand All @@ -42,6 +40,7 @@
from agent_env.bundle._secrets import scrub
from agent_env.bundle.installed import InstalledBundle, installed_bundles
from agent_env.bundle.parse import (
BUNDLE_TOML,
CONFIG_FILES,
LEAVINGS,
BundleError,
Expand All @@ -51,21 +50,20 @@
segment_problem,
)
from agent_env.bundle.plan import STORE_GETTERS, check_bundle
from agent_env.bundle.requires import Providers, Use, detected
from agent_env.bundle.resolve import resolve_bundle
from agent_env.config import get_config
from agent_env.entity.export import ExportContext, Unwritable, checked_path
from agent_env.entity.refs import EntityKind, EntityRef, RefRole, RefSite, default_agent_refs, ref_sites
from agent_env.entity.scopes import FieldScope, prune
from agent_env.env import registry as env_registry
from agent_env.env.env import Env
from agent_env.env.registry import get_env_registry
from agent_env.eval import Eval
from agent_env.plugins import ARTIFACTS, ENVS, TASK_STEPS, inventory, load_failures
from agent_env.plugins import ARTIFACTS, ENVS, TASK_STEPS
from agent_env.store.base import NotFoundError
from agent_env.store.ids import LOCAL_PREFIX
from agent_env.store.image_store.oci_registry_credentials import names_registry
from agent_env.task.task import Task
from agent_env.task_step import registry as task_step_registry
from agent_env.task_step.registry import get_task_step_registry
from agent_env.utils.paths import os_reason

Expand Down Expand Up @@ -171,7 +169,7 @@ def __init__(self, dest: Path, include_scopes: Collection[FieldScope] = (),
self.pinned: list[Pinned] = []
self.floating: list[Floating] = []
self.dropped: list[Dropped] = []
self.needs: list[Need] = []
self.needs: list[Use] = []
self.undeclared: list[str] = []
self.duplicates: list[str] = []
self.problems: list[str] = []
Expand All @@ -180,7 +178,7 @@ def __init__(self, dest: Path, include_scopes: Collection[FieldScope] = (),
self.tomls: dict[str, tuple[str, dict]] = {} # each entity folder's toml: its file's name, and its keys
self.downloads: dict[str, str] = {} # each file to write: its path in the bundle, and its object's url
self.jobs: list[ImageJob] = []
self.origins: dict[tuple[str, str], str] | None = None
self.providers = Providers()
self.installed: tuple[InstalledBundle, ...] | None = None
_check_destination(self.dest)

Expand Down Expand Up @@ -288,6 +286,8 @@ def write(self, description: str, source: dict) -> BundleImport:
if self.problems:
raise BundleError(self.problems)
shutil.rmtree(scratch)
if requires := detected(self.needs):
self.files[BUNDLE_TOML] = _toml.dumps({"requires": requires})
files = sorted([*self.files, PROVENANCE, "README.md"])
imported = BundleImport(
root=self.dest, files=tuple(files), tasks=tuple(self.tasks), eval=self.eval,
Expand Down Expand Up @@ -477,19 +477,10 @@ def _need_type(self, site: RefSite, entity: Any, where: str) -> None:
get_artifact_registry().get(name))

def _need(self, where: str, what: str, group: str, name: str, cls: type | None) -> None:
"""``name``, of the plugin ``group``, is a need when core doesn't ship it, named with what registers ``cls``,
the class it resolves to here: the distribution whose entry point names that class, or configuration. A plugin
the registry build skipped registers nothing, so it isn't loaded again to compare."""
if name in _BUILTINS[group]():
return
if self.origins is None:
self.origins = {(contribution.group, contribution.name): (distribution.name, contribution.value)
for distribution in inventory(load=False).distributions
for contribution in distribution.contributions}
distribution, value = self.origins.get((group, name), (None, None))
registered = value is not None and cls is not None and name not in load_failures().get(group, {})
by = distribution if registered and _names(value, cls) else "configuration"
self.needs.append(Need(where, what, name, by))
"""``name``, of the plugin ``group``, is a need when core doesn't ship it, with what provides ``cls``, the class
it resolves to here (``Providers.of``)."""
if (provider := self.providers.of(group, name, cls)) is not None:
self.needs.append(Use(where, what, name, provider))

def _default_agents(self, step: dict, refs: tuple[EntityRef, ...], where: str) -> None:
"""A step that leaves an agent field unset falls back to the configured default, which no field pins."""
Expand All @@ -511,11 +502,6 @@ def _name(self, kind: str, entity_id: str) -> str:
return name


# What core ships, per plugin group: anything else a task uses is a need.
_BUILTINS = {TASK_STEPS: task_step_registry._builtin_registry, ENVS: env_registry._builtin_registry,
ARTIFACTS: lambda: ARTIFACT_REGISTRY}


def _names_of(kind: BundleKind) -> str:
"""The folder whose names ``kind``'s share: artifacts' and skills' are one."""
return BundleKind.ARTIFACT.value if kind is BundleKind.SKILL else kind.value
Expand Down Expand Up @@ -549,18 +535,6 @@ def _fetch(url: str, dest: Path) -> None:
get_config().get_object_store_at(url).download_to_file(url, str(dest))


def _names(value: str, cls: type) -> bool:
"""Whether the entry point ``value``, ``module:attribute``, names ``cls``."""
module, _, attribute = value.partition(":")
try:
named = importlib.import_module(module)
for part in attribute.split("."):
named = getattr(named, part)
except (ImportError, AttributeError):
return False
return named is cls


def _output_names(sites: list[RefSite]) -> dict[tuple[EntityKind, str], str]:
"""The name each output takes: as written, or an ``@local`` id's last segment, numbered when it is taken."""
written = {_key(site) for site in sites if site.ref.role is RefRole.OUTPUT}
Expand Down
53 changes: 50 additions & 3 deletions src/agent_env/bundle/parse.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
from pathlib import Path
from typing import Any, NoReturn

from packaging.requirements import InvalidRequirement, Requirement

from agent_env.bundle._fs import Unreadable, fold, home, on_disk, read_regular, show, with_article
from agent_env.entity.refs import EntityKind
from agent_env.plugins._bundles import AGENT_ALIASES, ALIASES_FILE, ARTIFACT_ALIASES, ENV_ALIASES, read_aliases
Expand Down Expand Up @@ -87,6 +89,8 @@ class Bundle:
# The aliases its aliases.toml gives its agents and envs, by table and then folder name, which a reference names
# one by once the bundle is installed.
aliases: Mapping[str, Mapping[str, tuple[str, ...]]] = field(default_factory=dict)
# The plugin packages its bundle.toml requires, each a pip requirement; None when it has no bundle.toml.
requires: tuple[str, ...] | None = None


class BundleError(ValueError):
Expand Down Expand Up @@ -127,6 +131,7 @@ def __init__(self, root: Path, id_root: str | None, name: str | None):
self.problems: list[str] = []
self.ignored: list[Path] = []
self.aliases_file: Path | None = None
self.bundle_toml: Path | None = None
self.entry_point = id_root is not None
self.root_ok = True
if id_root is None:
Expand All @@ -145,6 +150,7 @@ def parse(self) -> Bundle:
entries = [entry for kind, path in kinds.items() for entry in self._scan_kind(kind, path)]
self._check_unique(entries)
aliases = self._aliases(entries)
requires = self._requires()
if self.problems:
raise BundleError(self.problems)
entries.sort(key=lambda entry: (_ORDER[entry.kind], entry.name))
Expand All @@ -156,6 +162,7 @@ def parse(self) -> Bundle:
entries=tuple(entries),
ignored=tuple(sorted(self.ignored)),
aliases=aliases,
requires=requires,
)

def _aliases(self, entries: list[BundleEntry]) -> dict[str, dict[str, tuple[str, ...]]]:
Expand Down Expand Up @@ -187,6 +194,41 @@ def _aliases(self, entries: list[BundleEntry]) -> dict[str, dict[str, tuple[str,
f"{table}.{name}: {alias!r} is the {noun} {other.name!r}'s {what}")
return declared

def _requires(self) -> tuple[str, ...] | None:
"""The plugin packages the bundle's bundle.toml requires, each a pip requirement naming a package by name and
version, not by URL; None when it has no bundle.toml."""
if self.bundle_toml is None:
return None
try:
data = tomllib.loads(self.bundle_toml.read_text(encoding="utf-8"))
except OSError as e:
self._problem(self.bundle_toml, f"can't be read: {os_reason(e)}")
return ()
except (UnicodeDecodeError, tomllib.TOMLDecodeError) as e:
self._problem(self.bundle_toml, f"isn't valid TOML: {e}")
return ()
for key in sorted(data.keys() - {"requires"}):
self._problem(self.bundle_toml, f"{key!r} isn't a key it takes; it takes requires")
requires = data.get("requires", [])
if not isinstance(requires, list) or not all(isinstance(line, str) for line in requires):
self._problem(self.bundle_toml, 'requires must be a list of pip requirements, such as '
'["agentenv-browser>=1.2"]')
return ()
for line in requires:
if not line.isprintable():
self._problem(self.bundle_toml, f"requires: {line!r} holds a character that can't be printed, so it "
"can't be shown as written before it's installed")
continue
try:
requirement = Requirement(line)
except InvalidRequirement as e:
self._problem(self.bundle_toml, f"requires: {line!r} isn't a pip requirement: {e}")
continue
if requirement.url:
self._problem(self.bundle_toml, f"requires: {line!r} names a URL; name the package and the versions "
"it needs, which install from wherever whoever runs the bundle installs from")
return tuple(requires)

def _derive_id_root(self) -> str:
id_root, problems = _id_root(self.root)
for path, message in problems:
Expand All @@ -212,9 +254,14 @@ def _scan_root(self) -> tuple[dict[BundleKind, Path], str | None]:
for entry in listing:
if entry.name in misspelled:
continue
if fold(entry.name) == fold(BUNDLE_TOML):
self._problem(entry.path, "reserved: a bundle takes its name from the folder and its "
"description from README.md")
if entry.name == BUNDLE_TOML:
node = self._node(Path(entry.path), entry)
if node == _FILE:
self.bundle_toml = Path(entry.path)
elif node == _DIR:
self._problem(entry.path, "must be a file, not a folder")
elif fold(entry.name) == fold(BUNDLE_TOML):
self._problem(entry.path, f"spell it {BUNDLE_TOML}, the file a bundle declares the plugins it needs in")
elif entry.name == ALIASES_FILE:
node = self._node(Path(entry.path), entry)
if node == _FILE:
Expand Down
Loading
Loading