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
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,14 @@ Out-of-tree code plugs in either through `config.toml`, as a `module:Class` poin
installed bundle's env, agent or artifact by its id, or as the configured default agent: the planner
pulls it into the run as one of the bundle's own (`bundle/plan.py`), and its ledger row is keyed by the
installed bundle, so every bundle shares one write of it. Such an id takes no version pin.
- A bundle can give its agents aliases in an `aliases.toml` at its root (`[agents] <folder> = ["<alias>", ...]`, read
by `plugins/_bundles.py`): the shipped `agents` bundle names Claude Code `claude-code`. A reference to an alias
names the agent its bundle gives it unless the run's bundle has an entity of that name or the store an agent of that
id, which wins. The planner pulls the agent in and writes the task naming it by its id (`Plan.aliases`); a step run
outside a bundle run gets it from `A2AAgent.get`, which reads the store first and an alias after (the planner reads
the store itself, as `STORE_GETTERS` does). agent-env's own bundles' aliases are theirs, and an alias two other
bundles give is refused, naming both. `agent-env a2a-agent list` lists the installed agents with their aliases, then
the store's; `a2a-agent get` prints the agent an alias names.
- 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
41 changes: 39 additions & 2 deletions src/agent_env/a2a_agent/a2a_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
from agent_env.config import get_config
from agent_env.config.model import MODEL_HEADERS_ENV
from agent_env.entity.refs import EntityRef
from agent_env.plugins._bundles import installed_aliases
from agent_env.store.base import NotFoundError
from agent_env.providers.sandbox_providers.sandbox import (
SANDBOX_LABEL,
NetworkPolicy,
Expand All @@ -41,7 +43,7 @@
from agent_env.attribution import Attribution

if TYPE_CHECKING:
from agent_env.a2a_agent.store import A2AAgentQuery
from agent_env.a2a_agent.store import A2AAgentQuery, A2AAgentStore
from agent_env.bundle.authoring import AuthoringContext
from agent_env.entity.export import ExportContext
from agent_env.artifact import CliArtifact
Expand Down Expand Up @@ -74,6 +76,15 @@ def unwritten_local_agent(agent_id: str, field: str) -> str:
"default_a2a_agent_id at an agent the store holds")


def _holds(store: A2AAgentStore, agent_id: str) -> bool:
"""Whether ``store`` has an agent of ``agent_id``, at any version."""
try:
store.get(agent_id)
except NotFoundError:
return False
return True


@dataclass
class DeployedA2AAgent:
agent_id: str
Expand Down Expand Up @@ -398,8 +409,34 @@ def accept_toml(cls, data: dict[str, Any], ctx: AuthoringContext) -> dict[str, A

@classmethod
def get(cls, id: str, version: Optional[int] = None) -> A2AAgent:
"""The agent ``id`` names: the store's agent of that id, else the agent an installed bundle gives the alias
``id``, which the store holds once a run of a bundle has used it. An alias takes no version, and a version the
store's agent of that id lacks is missing. Raises NotFoundError."""
from .store import get_a2a_agent_store
return get_a2a_agent_store().get(id, version)
store = get_a2a_agent_store()
try:
return store.get(id, version)
except NotFoundError:
if not isinstance(id, str) or id.startswith("@") or "/" in id: # neither an alias nor close to one
raise
aliases = installed_aliases()
named, refusal = aliases.target(id), aliases.refusal(id)
if named is None and refusal is None:
if version is None and (hint := aliases.hint(id)):
raise NotFoundError(f"there is no agent {id!r} in the store{hint}") from None
raise
if version is not None and _holds(store, id):
raise
if named is None:
raise NotFoundError(refusal) from None
if version is not None:
raise NotFoundError(f"{id!r} is an alias of {named.agent_id}, which comes with {named.declared_by}, so "
"it has no fixed version; drop the pin") from None
try:
return store.get(named.agent_id)
except NotFoundError:
raise NotFoundError(f"{id!r} is an alias of {named.agent_id}, which comes with {named.declared_by} and isn't "
"in the store yet: an `agent-env run` of a bundle that uses it writes it") from None

@classmethod
def put(cls, **kwargs) -> A2AAgent:
Expand Down
5 changes: 4 additions & 1 deletion src/agent_env/agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ Agents that come with agent-env, each an agent folder a bundle builds and deploy
`agents/claude-code/` runs Anthropic's [Claude Code](https://code.claude.com/docs/en/headless) CLI behind an A2A
server: the prompt goes to `claude -p`, the task env's MCP servers to `--mcp-config`, and its stream-json output is the
turn's trajectory. It's the default agent, so a `deploy_agent` or an agent judge that names no agent deploys it. Name it
from another bundle as `@local/agentenv-framework/agents/claude-code`.
from another bundle as `claude-code`, the alias `aliases.toml` gives it, or by its id,
`@local/agentenv-framework/agents/claude-code`; an agent of your own with the id `claude-code`, in your bundle or your
store, is the one `claude-code` names instead. `agent-env a2a-agent list` lists the agents a task can name, with
their aliases.

It needs Docker, and a model endpoint serving Anthropic's Messages API, `<base>/v1/messages`, which a LiteLLM gateway
does: the `[model]` base URL and key reach it as `ANTHROPIC_BASE_URL` and a bearer token, and `[model.headers]` are sent
Expand Down
4 changes: 4 additions & 0 deletions src/agent_env/agents/aliases.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Ids a task can name these agents by; a task's own bundle, or the store, wins over an alias.
[agents]
claude-code = ["claude-code"]
pi = ["pi"]
11 changes: 2 additions & 9 deletions src/agent_env/bundle/_fs.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@

from __future__ import annotations

import errno
import os
import stat
import sys
import unicodedata
from pathlib import Path

from agent_env.utils.paths import os_reason

try:
import fcntl
import pwd
Expand Down Expand Up @@ -100,14 +101,6 @@ def fold(name: str) -> str:
return unicodedata.normalize("NFC", unicodedata.normalize("NFC", name).casefold())


def os_reason(e: OSError) -> str:
if e.errno == errno.ELOOP:
return "a symlink loop, or too many links"
if isinstance(e, PermissionError):
return "permission denied"
return e.strerror or str(e)


def with_article(noun: str) -> str:
return f"{'an' if noun[:1] in 'aeiou' else 'a'} {noun}"

Expand Down
4 changes: 3 additions & 1 deletion src/agent_env/bundle/authoring.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@
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 agent_env.utils.paths import os_reason

from ._fs import relative, show, with_article
from .parse import CONFIG_FILES, LEAVINGS, NAMED_BY, Bundle, BundleEntry, BundleError

A = TypeVar("A", bound=Artifact)
Expand Down
3 changes: 2 additions & 1 deletion src/agent_env/bundle/importer.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
from agent_env.artifact.artifacts.skill import SkillArtifact
from agent_env.artifact.registry import ARTIFACT_REGISTRY, canonical_type, get_artifact_registry
from agent_env.bundle import _toml
from agent_env.bundle._fs import fold, os_reason, show
from agent_env.bundle._fs import fold, show
from agent_env.bundle._images import ImageJob, context_groups, write_images
from agent_env.bundle._import_report import (
PROVENANCE,
Expand Down Expand Up @@ -67,6 +67,7 @@
from agent_env.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

# How long a name taken from an id may be, in bytes; ids are capped far above it, and a longer name helps nobody.
_MAX_NAME_BYTES = 200
Expand Down
123 changes: 5 additions & 118 deletions src/agent_env/bundle/installed.py
Original file line number Diff line number Diff line change
@@ -1,75 +1,21 @@
"""Bundles installed with a package, found through the ``agent_env.bundles`` entry points.

A distribution registers ``<name> = "<package>"`` and ships the bundle folder at ``<package>/<name>/``. The folder is
found without importing any package code: only the top-level package is located, and the rest of the path is walked
as folders. An installed bundle's ids are rooted at its distribution, ``@local/<dist>/<name>``. A name several
distributions install is run as ``<dist>/<name>``, except that agent-env's own bundles keep their bare names.
"""Bundles installed with a package, found through the ``agent_env.bundles`` entry points (``agent_env.plugins._bundles``
finds them), and run by name: a name several distributions install is run as ``<dist>/<name>``, except that agent-env's
own bundles keep their bare names.
"""

from __future__ import annotations

import difflib
import importlib.util
import os
from collections import Counter
from dataclasses import dataclass
from importlib.metadata import EntryPoint
from pathlib import Path

from packaging.utils import canonicalize_name

from agent_env.plugins._discovery import Plugin, claims, identity
from agent_env.plugins._report import INCOMPATIBLE_CORE, INVALID_PLUGIN, NAME_CONFLICT
from agent_env.plugins._requirements import incompatibility
from agent_env.store.ids import LOCAL_PREFIX
from agent_env.plugins._bundles import BUNDLES, CORE, InstalledBundle, installed_bundles

from ._fs import os_reason
from .parse import Bundle, BundleError, parse_bundle
from .plan import check_bundle
from .resolve import resolve_bundle

BUNDLES = "agent_env.bundles"
CORE = "agentenv-framework"


@dataclass(frozen=True)
class InstalledBundle:
"""A bundle an installed distribution registers: its folder, or why it can't run."""

name: str
package: str # the distribution, as its metadata spells it
version: str
value: str # the package the entry point names
root: Path | None
code: str | None # the plugin report's code for ``problem``
problem: str | None

@property
def dist(self) -> str:
return canonicalize_name(self.package)

@property
def qualified(self) -> str:
return f"{self.dist}/{self.name}"

@property
def id_root(self) -> str:
return f"{LOCAL_PREFIX}{self.dist}/{self.name}"


class _Unresolved(Exception):
pass


def installed_bundles() -> tuple[InstalledBundle, ...]:
"""Every bundle installed distributions register, in name order, each resolved to its folder or with the
reason it can't run. Raises what reading the installed metadata raises."""
found = []
for name, registrations in claims(BUNDLES).items():
per_package = Counter(plugin.dist for plugin, _ in registrations)
for plugin, ep in registrations:
found.append(_installed(name, plugin, ep, repeated=per_package[plugin.dist] > 1))
return tuple(found)
__all__ = ["BUNDLES", "CORE", "InstalledBundle", "checked", "find_bundle", "installed_bundles", "run_name"]


def find_bundle(text: str, bundles: tuple[InstalledBundle, ...] | None = None) -> InstalledBundle:
Expand Down Expand Up @@ -106,65 +52,6 @@ def checked(bundle: InstalledBundle, *, load: bool = True) -> Bundle:
return parsed


def _installed(name: str, plugin: Plugin, ep: EntryPoint, *, repeated: bool) -> InstalledBundle:
package, version = identity(ep)

def installed(root: Path | None = None, code: str | None = None, problem: str | None = None) -> InstalledBundle:
return InstalledBundle(name, package, version, plugin.value, root, code, problem)

if not plugin.dist:
return installed(code=INVALID_PLUGIN, problem="its distribution's metadata can't be read")
if repeated:
return installed(code=NAME_CONFLICT, problem=f"{package} registers the bundle {name!r} more than once")
if unmet := incompatibility(getattr(ep, "dist", None)):
return installed(code=INCOMPATIBLE_CORE, problem=f"{package} {unmet}")
try:
return installed(root=_folder(ep, name))
except _Unresolved as e:
return installed(code=INVALID_PLUGIN, problem=str(e))


def _folder(ep: EntryPoint, name: str) -> Path:
"""The folder ``<package>/<name>`` the entry point names, found without importing the package."""
# Not ep.module/ep.attr: on a value that doesn't parse they raise AttributeError, or AssertionError from 3.13 on.
match = ep.pattern.match(ep.value)
package, attr = (match.group("module"), match.group("attr")) if match else (None, None)
if package is None or attr:
raise _Unresolved(f"the entry point's value {ep.value!r} isn't a package name; name the package that holds "
f"the {name!r} folder")
if "/" in name:
raise _Unresolved(f"the bundle name {name!r} contains '/'")
top, *rest = package.split(".")
try:
spec = importlib.util.find_spec(top)
except (ImportError, ValueError):
spec = None
if spec is None or not spec.submodule_search_locations:
raise _Unresolved(f"{top!r} isn't an installed package")
# A namespace package can have several locations, and an editable install can add ones that aren't folders.
locations = [Path(location) for location in spec.submodule_search_locations if os.path.isdir(location)]
if not locations:
raise _Unresolved(f"the package {top!r} isn't unpacked on disk; reinstall it unpacked")
for location in locations:
if (folder := _spelled(location, (*rest, name))) is not None:
return folder
raise _Unresolved(f"the package {package!r} has no folder {name!r}")


def _spelled(location: Path, parts: tuple[str, ...]) -> Path | None:
"""``location/parts`` when each part is a folder spelled exactly so, following links without resolving them,
so a case-insensitive filesystem and a linked folder are judged as they are elsewhere."""
folder = location
for part in parts:
try:
if part not in os.listdir(folder) or not os.path.isdir(folder / part):
return None
except OSError as e:
raise _Unresolved(f"the folder {folder} can't be read: {os_reason(e)}") from None
folder /= part
return folder


def _unknown(text: str, bundles: tuple[InstalledBundle, ...]) -> str:
if not bundles:
return f"{text!r} is neither a folder nor an installed bundle, and no bundles are installed"
Expand Down
Loading
Loading