diff --git a/AGENTS.md b/AGENTS.md index 240e6413..8daa9e3b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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] = ["", ...]`, 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.]` is reserved for an installed plugin's own settings, read with diff --git a/src/agent_env/a2a_agent/a2a_agent.py b/src/agent_env/a2a_agent/a2a_agent.py index 4642fd3a..d9714f50 100644 --- a/src/agent_env/a2a_agent/a2a_agent.py +++ b/src/agent_env/a2a_agent/a2a_agent.py @@ -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, @@ -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 @@ -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 @@ -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: diff --git a/src/agent_env/agents/README.md b/src/agent_env/agents/README.md index 4b88aa0c..4c5a5c89 100644 --- a/src/agent_env/agents/README.md +++ b/src/agent_env/agents/README.md @@ -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, `/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 diff --git a/src/agent_env/agents/aliases.toml b/src/agent_env/agents/aliases.toml new file mode 100644 index 00000000..2c4e5473 --- /dev/null +++ b/src/agent_env/agents/aliases.toml @@ -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"] diff --git a/src/agent_env/bundle/_fs.py b/src/agent_env/bundle/_fs.py index 2a177e66..37d3e890 100644 --- a/src/agent_env/bundle/_fs.py +++ b/src/agent_env/bundle/_fs.py @@ -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 @@ -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}" diff --git a/src/agent_env/bundle/authoring.py b/src/agent_env/bundle/authoring.py index ed99eaeb..0cf1f056 100644 --- a/src/agent_env/bundle/authoring.py +++ b/src/agent_env/bundle/authoring.py @@ -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) diff --git a/src/agent_env/bundle/importer.py b/src/agent_env/bundle/importer.py index ccfd3abd..c06b5ec3 100644 --- a/src/agent_env/bundle/importer.py +++ b/src/agent_env/bundle/importer.py @@ -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, @@ -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 diff --git a/src/agent_env/bundle/installed.py b/src/agent_env/bundle/installed.py index 72268a68..39c7b218 100644 --- a/src/agent_env/bundle/installed.py +++ b/src/agent_env/bundle/installed.py @@ -1,75 +1,21 @@ -"""Bundles installed with a package, found through the ``agent_env.bundles`` entry points. - -A distribution registers `` = ""`` and ships the bundle folder at ``//``. 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//``. A name several -distributions install is run as ``/``, 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 ``/``, 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: @@ -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 ``/`` 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" diff --git a/src/agent_env/bundle/parse.py b/src/agent_env/bundle/parse.py index a1816084..4784e14b 100644 --- a/src/agent_env/bundle/parse.py +++ b/src/agent_env/bundle/parse.py @@ -15,16 +15,17 @@ import tomllib import unicodedata from collections import Counter, defaultdict -from collections.abc import Iterable +from collections.abc import Iterable, Mapping from dataclasses import dataclass, field from enum import Enum from pathlib import Path from typing import Any, NoReturn -from agent_env.bundle._fs import Unreadable, fold, home, on_disk, os_reason, read_regular, show +from agent_env.bundle._fs import Unreadable, fold, home, on_disk, read_regular, show from agent_env.entity.refs import EntityKind +from agent_env.plugins._bundles import ALIASES_FILE, read_aliases from agent_env.store.ids import LOCAL_PREFIX, MAX_AUTHORED_LOCAL_ID_BYTES, validate_local_id -from agent_env.utils.paths import LEAVINGS +from agent_env.utils.paths import LEAVINGS, os_reason BUNDLE_TOML = "bundle.toml" DOCKERFILE = "Dockerfile" @@ -83,6 +84,9 @@ class Bundle: description: str | None # README.md's first line of text entries: tuple[BundleEntry, ...] ignored: tuple[Path, ...] # root-relative, as on disk: entries that are neither kinds nor entities + # The aliases its aliases.toml gives its agents, by agent name, which a reference names an agent by once the bundle + # is installed. + aliases: Mapping[str, tuple[str, ...]] = field(default_factory=dict) class BundleError(ValueError): @@ -122,6 +126,7 @@ def __init__(self, root: Path, id_root: str | None, name: str | None): self.name = name or unicodedata.normalize("NFC", root.name) self.problems: list[str] = [] self.ignored: list[Path] = [] + self.aliases_file: Path | None = None self.entry_point = id_root is not None self.root_ok = True if id_root is None: @@ -139,6 +144,7 @@ def parse(self) -> Bundle: kinds, description = self._scan_root() entries = [entry for kind, path in kinds.items() for entry in self._scan_kind(kind, path)] self._check_unique(entries) + aliases = self._aliases(entries) if self.problems: raise BundleError(self.problems) entries.sort(key=lambda entry: (_ORDER[entry.kind], entry.name)) @@ -149,8 +155,33 @@ def parse(self) -> Bundle: description=description, entries=tuple(entries), ignored=tuple(sorted(self.ignored)), + aliases=aliases, ) + def _aliases(self, entries: list[BundleEntry]) -> dict[str, tuple[str, ...]]: + """The aliases the bundle's aliases.toml gives its agents. Each names an agent the bundle has, by its folder + name, so that agent keeps the id its folder gives it, and none is another of its agents' name or id.""" + if self.aliases_file is None: + return {} + declared, problems = read_aliases(self.aliases_file) + for problem in problems: + self._problem(self.aliases_file, problem) + agents = {entry.name: entry for entry in entries if entry.kind is BundleKind.AGENT} + taken = {key: entry for entry in agents.values() for key in (entry.name, entry.id)} + for name, aliases in declared.items(): + agent = agents.get(name) + if agent is None: + self._problem(self.aliases_file, f"agents.{name}: this bundle has no agent {name!r}") + continue + if agent.id != f"{self.id_root}/{name}": + self._problem(self.aliases_file, f"agents.{name}: the agent declares the id {agent.id!r}, and an alias " + f"names an agent by its folder, so it must keep the id {self.id_root}/{name}") + for alias in aliases: + if (other := taken.get(alias)) is not None and other is not agent: + what = "name" if alias == other.name else "id" + self._problem(self.aliases_file, f"agents.{name}: {alias!r} is the agent {other.name!r}'s {what}") + return declared + def _derive_id_root(self) -> str: id_root, problems = _id_root(self.root) for path, message in problems: @@ -179,6 +210,14 @@ def _scan_root(self) -> tuple[dict[BundleKind, Path], str | None]: 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") + elif entry.name == ALIASES_FILE: + node = self._node(Path(entry.path), entry) + if node == _FILE: + self.aliases_file = Path(entry.path) + elif node == _DIR: + self._problem(entry.path, "must be a file, not a folder") + elif fold(entry.name) == fold(ALIASES_FILE): + self._problem(entry.path, f"spell it {ALIASES_FILE}, the file a bundle gives its agents' aliases in") elif entry.name in by_dir: node = self._node(Path(entry.path), entry) if node == _DIR: diff --git a/src/agent_env/bundle/plan.py b/src/agent_env/bundle/plan.py index ec61f51b..63c087a9 100644 --- a/src/agent_env/bundle/plan.py +++ b/src/agent_env/bundle/plan.py @@ -9,11 +9,14 @@ A reference by id to an env, agent or artifact an installed bundle defines, and a step's fallback to the configured default agent when an installed bundle defines it, pulls that entity into the plan with what it references in its bundle, so the run checks and writes it as it does its own. Such an id has no fixed version, and one the installed -bundle doesn't define is refused rather than read from the store. +bundle doesn't define is refused rather than read from the store. A reference to an agent by an alias an installed +bundle gives it (its ``aliases.toml``) does the same, unless the store has an agent of that id, which it names instead; +the task is written naming the agent by its id. """ from __future__ import annotations +import copy import heapq import unicodedata from collections.abc import Callable, Mapping, Sequence @@ -22,12 +25,13 @@ from typing import TYPE_CHECKING, Any from agent_env.a2a_agent import A2AAgent +from agent_env.a2a_agent.store import get_a2a_agent_store from agent_env.artifact import Artifact, CliArtifact, EnvironmentArtifact, SeedsEnvironments from agent_env.artifact.artifact import FolderChild from agent_env.artifact.artifacts.docker_image import DockerImageArtifact from agent_env.artifact.registry import canonical_type, get_artifact_registry from agent_env.config import get_config -from agent_env.entity.refs import EntityKind, default_agent_refs +from agent_env.entity.refs import EntityKind, RefRole, default_agent_refs, ref_sites from agent_env.env.env import Env from agent_env.env.envs._deployment import provider_refusal from agent_env.env.envs.mcp_server import MCPServerEnv @@ -36,6 +40,7 @@ from agent_env.env.published_images import published_agent_image from agent_env.env.registry import get_env_registry from agent_env.env.store import ENVS_COLLECTION +from agent_env.plugins._bundles import InstalledAliases, installed_aliases from agent_env.providers.env_providers.constants import ( AGENT_ENV_WEBSITE_BACKEND_SUFFIX, AGENT_ENV_WEBSITE_FRONTEND_SUFFIX, @@ -65,7 +70,13 @@ # The kinds a reference pulls in from an installed bundle. _PULLED = (EntityKind.ENV, EntityKind.AGENT, EntityKind.ARTIFACT) -STORE_GETTERS = {EntityKind.ENV: Env.get, EntityKind.AGENT: A2AAgent.get, EntityKind.ARTIFACT: Artifact.get, + +def _stored_agent(id: str, version: int | None) -> A2AAgent: + """The store's agent of ``id`` itself: a plan weighs an installed alias against the store on its own.""" + return get_a2a_agent_store().get(id, version) + + +STORE_GETTERS = {EntityKind.ENV: Env.get, EntityKind.AGENT: _stored_agent, EntityKind.ARTIFACT: Artifact.get, EntityKind.TASK: Task.get} # The type a field naming an environment artifact takes: what an artifact referencing one holds. @@ -100,6 +111,22 @@ def unwritten(self, image_id: str) -> DockerImageArtifact: source_digest=self.sources) +@dataclass(frozen=True) +class AliasUse: + """An agent alias the run names, and the agent it names: the one an installed bundle gives the alias or, when the + store has an agent of that id, that one, which shadows it.""" + + alias: str + target_id: str # the agent the installed bundle gives the alias + declared_by: str # "'s bundle" + store_version: int | None = None # the store's agent of the alias's id, when it shadows the alias + + @property + def agent_id(self) -> str: + """The id of the agent the alias names in this run.""" + return self.alias if self.store_version is not None else self.target_id + + @dataclass(frozen=True) class Write: """An entity or built image a run writes.""" @@ -122,6 +149,7 @@ class Plan: # Each agent image the bundle builds from a shipped agent's sources, by id: the image published for them, which the # run registers in place of its build context alone. published: Mapping[str, Publication] = field(default_factory=dict) + aliases: Mapping[str, AliasUse] = field(default_factory=dict) # each agent alias the run names, by alias def bundle_of(self, entry: BundleEntry) -> Bundle: """The bundle ``entry`` comes from: the installed one for an entry pulled in from one, else the run's.""" @@ -177,6 +205,7 @@ def __init__(self, resolved: ResolvedBundle, installed: Installed | None = None) self.others: tuple[InstalledBundle, ...] | None = None # the installed bundles but this one, once listed self.pullable: dict[str, _Pullable | str] = {} # each installed bundle read, by id root, or why it can't be self.unpulled: dict[tuple[EntityKind, str, int | None], str] = {} # why an installed bundle's id isn't pulled + self.alias_table: InstalledAliases | None = None # the installed aliases, once a plan reads them self.pulling: set[_Key] = set() self.own = _by_id(resolved.bundle) # what a pulled entity's id names in this bundle images: dict[BundleEntry, list[BundleImage]] = {} @@ -210,22 +239,91 @@ def _localized(self, entry: ResolvedEntry) -> ResolvedEntry: return entry return replace(entry, references=tuple(references)) - def _default_agents(self, task: ResolvedEntry) -> list[Reference]: - """A reference to the configured default agent for each step of ``task`` that leaves an agent field unset, - when this bundle or an installed one defines that agent. One in the store is preflight's to check.""" + def _default_agents(self, task: ResolvedEntry, agent_id: str | None = None) -> list[Reference]: + """A reference to the configured default agent, or ``agent_id``, for each step of ``task`` that leaves an agent + field unset, when this bundle or an installed one defines that agent. One in the store is preflight's to + check.""" registry = get_task_step_registry() found = [] for step in task.config: cls = registry.get(step.get("type")) if isinstance(step.get("type"), str) else None for field_ref in default_agent_refs(cls.entity_refs or () if cls is not None else (), step): version = step.get(field_ref.version_field) if field_ref.version_field else None - ref = Reference(EntityKind.AGENT, get_config().get_default_a2a_agent_id(), version, None, + ref = Reference(EntityKind.AGENT, agent_id or get_config().get_default_a2a_agent_id(), version, None, f"step {step.get('id')!r}: {field_ref.path} (the default agent)", None) pulled = self._pulled(ref) if pulled.local is not None or (ref.kind, ref.id, ref.version) in self.unpulled: found.append(pulled) return found + # Agents named by an installed bundle's alias + + def _resolve_aliases(self) -> dict[str, AliasUse]: + """Point each task's reference to an agent by an alias an installed bundle gives it, and the configured default + when it's one, at that agent, pulled into the plan, unless the store has an agent of the alias's id, which the + reference keeps naming. A field naming the alias is rewritten to the agent's id. A reference that can't be + pointed at the alias's agent is left with the problem the store ref check reports.""" + if self.installed is None: + return {} + table, uses, stored, default = installed_aliases(self.installed()), {}, {}, _UNREAD + self.alias_table = table + for index, entry in enumerate(self.entries): + if entry.entry.kind is not BundleKind.TASK: + continue + references, aliased = [], {} + for ref in entry.references: + use = None + if ref.kind is EntityKind.AGENT and ref.local is None: + use = self._alias_use(table, ref.id, ref.version, uses, stored) + if isinstance(use, str): + self.unpulled[ref.kind, ref.id, ref.version] = use + elif use is not None and use.store_version is None: + aliased[ref.id] = use.target_id + ref = self._pulled(replace(ref, id=use.target_id)) + references.append(ref) + if _leaves_an_agent_unset(entry): + if default is _UNREAD: + default = self._alias_use(table, get_config().get_default_a2a_agent_id(), None, uses, stored) + if isinstance(default, str): + self.problems.append(f"[agents] default_a2a_agent_id: {default}") + if isinstance(default, AliasUse) and default.store_version is None: + references += self._default_agents(entry, default.target_id) + if references != list(entry.references): + self._replace(index, replace(entry, config=_renamed(entry.config, aliased), references=tuple(references))) + return uses + + def _alias_use(self, table: InstalledAliases, agent_id: str, version: int | None, uses: dict[str, AliasUse], + stored: dict[str, int | None]) -> AliasUse | str | None: + """What ``agent_id`` names when it's an installed alias, the store's agent of that id read first (its latest + version, or None, cached in ``stored``), recorded in ``uses``; why it can't be named so; or None when it's no + alias, or names the store's agent as any store reference does: pinned, or with the alias refused.""" + if agent_id.startswith(LOCAL_PREFIX): + return None + refusal, named = table.refusal(agent_id), table.target(agent_id) + if refusal is None and named is None: + return None + if agent_id not in stored: + try: + stored[agent_id] = STORE_GETTERS[EntityKind.AGENT](agent_id, None).version + except NotFoundError: + stored[agent_id] = None + except (ValueError, KeyError, TypeError): # one the store has but can't read, which the store ref check says + return None + if stored[agent_id] is not None and (refusal is not None or version is not None): + return None + if refusal is not None: + return refusal + if version is not None: + return (f"{agent_id!r} is an alias of {named.agent_id}, which comes with {named.declared_by}, so it has no " + "fixed version; drop the pin") + return uses.setdefault(agent_id, AliasUse(agent_id, named.agent_id, named.declared_by, stored[agent_id])) + + def _replace(self, index: int, entry: ResolvedEntry) -> None: + """Put ``entry`` in place of the bundle's own entry at ``index``, with what it now references.""" + key = _key(entry.entry) + self.entries[index] = self.nodes[key] = entry + self.edges[key] = _needs(entry) + def _pulled(self, ref: Reference) -> Reference: """``ref``, a store reference, pointing at what its id names: this bundle's entity, for a reference a pulled entity or a default makes, or an installed bundle's, pulled into the plan. One naming neither stays a store @@ -306,6 +404,7 @@ def _pull(self, pullable: _Pullable, target: BundleEntry | BundleImage) -> None: self._add(key, source, _needs(source)) def plan(self, tasks: Sequence[str], evals: Sequence[str]) -> Plan: + aliases = self._resolve_aliases() self._check_writers() self._check_loops() selected_tasks, selected_evals = self._selection(tasks, evals) @@ -322,7 +421,7 @@ def plan(self, tasks: Sequence[str], evals: Sequence[str]) -> Plan: raise BundleError(self.problems) writes = self._ordered(closure) return Plan(self.resolved, writes, tuple(selected_tasks), tuple(selected_evals), store_refs, store_latest, - dict(self.origins), self._published(writes)) + dict(self.origins), self._published(writes), aliases) def check(self) -> None: self._check_writers() @@ -514,7 +613,10 @@ def _store_ref_problem(self, ref: Reference, read: dict) -> str | None: if found is None: at = f" version {ref.version}" if ref.version is not None else "" other = self.named.get(fold(ref.id)) - hint = f"; this bundle's {other.kind.store} {other.name!r} has that name" if other is not None else "" + if other is not None: + hint = f"; this bundle's {other.kind.store} {other.name!r} has that name" + else: + hint = self.alias_table.hint(ref.id) if ref.kind is EntityKind.AGENT and self.alias_table else "" return f"there is no {kind} {ref.id!r}{at} in the store{hint}" if isinstance(found, str): return f"{kind} {ref.id!r} {found}" @@ -881,6 +983,33 @@ def folder_files(bundle: Bundle, entry: BundleEntry) -> Mapping[str, Path]: return entry_files(bundle, entry, empty_ok=True) +_UNREAD: Any = object() # the configured default agent, before a task needs it + + +def _leaves_an_agent_unset(task: ResolvedEntry) -> bool: + """Whether a step of ``task`` leaves an agent field unset, so deploys the configured default agent.""" + registry = get_task_step_registry() + for step in task.config: + cls = registry.get(step.get("type")) if isinstance(step.get("type"), str) else None + if any(default_agent_refs(cls.entity_refs or () if cls is not None else (), step)): + return True + return False + + +def _renamed(steps: Any, ids: Mapping[str, str]) -> Any: + """A copy of a task's ``steps`` with each agent field naming an alias in ``ids`` naming that agent's id instead.""" + if not ids: + return steps + steps = copy.deepcopy(steps) + registry = get_task_step_registry() + for step in steps: + cls = registry.get(step.get("type")) if isinstance(step.get("type"), str) else None + for site in ref_sites(cls.entity_refs or () if cls is not None else (), step): + if site.ref.kind is EntityKind.AGENT and site.ref.role is RefRole.INPUT and site.value in ids: + site.rewrite(ids[site.value]) + return steps + + def _needs(entry: ResolvedEntry) -> list[_Key]: """The writes ``entry`` references, each once.""" return list(dict.fromkeys(_key(ref.local) for ref in entry.references if ref.local is not None)) diff --git a/src/agent_env/bundle/preflight.py b/src/agent_env/bundle/preflight.py index 6147bdc5..47d97637 100644 --- a/src/agent_env/bundle/preflight.py +++ b/src/agent_env/bundle/preflight.py @@ -331,7 +331,7 @@ def _agent_deploy(self, where: str, provider: SandboxProvider, agent_id: str | N """Check an agent deploy, ``given`` the model variables its step sets, or ``needs`` those the config must.""" if agent_id is None: self.default_agent_users.append((where, unnamed)) - agent_id = get_config().get_default_a2a_agent_id() + agent_id = self._default_agent_id() if needed := needs if needs is not None else _MODEL_VARS - given - self._agent_env_vars(agent_id, version): self.model_users.append((where, needed)) image = self._agent_image(agent_id, version) @@ -487,10 +487,15 @@ def _docker_plugins(self, built_here: frozenset[str], infra: list[InfraSetup]) - if builds and (missing := docker_plugin_missing("buildx")): self.run_problems.append(f"the run builds {_listed(builds)} with this machine's Docker, and {missing}") + def _default_agent_id(self) -> str: + """The agent a step naming none deploys: the configured default, or the agent it names when it's an alias.""" + default = get_config().get_default_a2a_agent_id() + return self.plan.aliases[default].agent_id if default in self.plan.aliases else default + def _default_agent(self) -> None: if not self.default_agent_users: return - agent_id = get_config().get_default_a2a_agent_id() + agent_id = self._default_agent_id() if agent_id in self.agents: return try: diff --git a/src/agent_env/bundle/run.py b/src/agent_env/bundle/run.py index ca7eb676..6b1f9d43 100644 --- a/src/agent_env/bundle/run.py +++ b/src/agent_env/bundle/run.py @@ -37,7 +37,7 @@ from .installed import installed_bundles from .materialize import Materialization, Materialized, materialize from .parse import BundleEntry, BundleError, BundleKind, parse_bundle -from .plan import Plan, Write, plan_bundle +from .plan import AliasUse, Plan, Write, plan_bundle from .preflight import Preflight, preflight_run from .resolve import BuiltImage, BundleImage, Reference, ReferencedImage, resolve_bundle @@ -229,6 +229,8 @@ def run_bundle( say = _progress(on_progress) with namespace_routing(): plan, preflight = _planned(root, tasks, evals, id_root, sandbox) + for use in plan.aliases.values(): + say(_aliased(use)) materialization = materialize( plan, on_wait=lambda: say("waiting for another agent-env run to finish writing this bundle's ids"), @@ -277,6 +279,8 @@ def dry_run_bundle( say = _progress(on_progress) with namespace_routing(): plan, preflight = _planned(root, tasks, evals, id_root, sandbox) + for use in plan.aliases.values(): + say(_aliased(use)) materialization = materialize(plan, dry_run=True, on_write=lambda done: say(_written(plan, done))) runs = _to_run(plan) return DryRun(materialization, runs, _skipped(plan, runs, every=not tasks and not evals), preflight.infra, @@ -455,6 +459,14 @@ def _task_run_command(ref: Reference) -> str: return f"agent-env task run --id {shlex.quote(ref.id)}{version}" +def _aliased(use: AliasUse) -> str: + """Which agent an alias the run names is: the one an installed bundle gives it, or the store's of its id.""" + if use.store_version is None: + return f"{use.alias} → {use.target_id} (the alias {use.declared_by} gives it)" + return (f"{use.alias} → the store's agent {use.alias!r} v{use.store_version}, which shadows the alias " + f"{use.declared_by} gives {use.target_id}") + + def _written(plan: Plan, done: Materialized) -> str: what = f"{_label(plan, done.write)}: v{done.version}" if done.reused: diff --git a/src/agent_env/cli/a2a_agent/__init__.py b/src/agent_env/cli/a2a_agent/__init__.py index 154c9591..bedae9e3 100644 --- a/src/agent_env/cli/a2a_agent/__init__.py +++ b/src/agent_env/cli/a2a_agent/__init__.py @@ -4,6 +4,7 @@ from .deploy import deploy from .get import get from .get_instance import get_instance +from .list_agents import list_agents from .put import put from .validate import validate @@ -16,6 +17,7 @@ def a2a_agent(): a2a_agent.add_command(put) a2a_agent.add_command(get) +a2a_agent.add_command(list_agents) a2a_agent.add_command(deploy) a2a_agent.add_command(get_instance) a2a_agent.add_command(validate) diff --git a/src/agent_env/cli/a2a_agent/get.py b/src/agent_env/cli/a2a_agent/get.py index 9c6d250d..b281af14 100644 --- a/src/agent_env/cli/a2a_agent/get.py +++ b/src/agent_env/cli/a2a_agent/get.py @@ -3,13 +3,17 @@ import click from agent_env.a2a_agent import A2AAgent +from agent_env.plugins._bundles import installed_aliases @click.command() -@click.option("--id", "agent_id", required=True, help="A2A agent id") +@click.option("--id", "agent_id", required=True, help="A2A agent id, or an alias an installed bundle gives an agent") @click.option("--version", "agent_version", default=None, type=int, help="Agent version (defaults to latest)") def get(agent_id: str, agent_version: int | None): - """Get an A2A agent and output its definition as JSON.""" + """Get an A2A agent and output its definition as JSON: the agent a step naming that id deploys, so an alias gets + the agent it names.""" agent = A2AAgent.get(agent_id, agent_version) + if agent.id != agent_id and (named := installed_aliases().target(agent_id)) is not None: + click.echo(f"{agent_id} → {agent.id} (the alias {named.declared_by} gives it)", err=True) click.echo(json.dumps(agent.to_dict(), indent=2)) diff --git a/src/agent_env/cli/a2a_agent/list_agents.py b/src/agent_env/cli/a2a_agent/list_agents.py new file mode 100644 index 00000000..f0659dcd --- /dev/null +++ b/src/agent_env/cli/a2a_agent/list_agents.py @@ -0,0 +1,101 @@ +import json +from dataclasses import asdict, dataclass, field + +import click + +from agent_env.a2a_agent.store import A2A_AGENTS_COLLECTION +from agent_env.bundle import BundleError, BundleKind +from agent_env.bundle.installed import checked, installed_bundles +from agent_env.config import get_config +from agent_env.plugins._bundles import InstalledAliases, InstalledBundle, installed_aliases +from agent_env.store import Filter, Sort + +_LIMIT = 50 + + +@dataclass +class _Agent: + id: str + source: str # "installed" or "store" + version: int | None = None # the store's latest + aliases: list[str] = field(default_factory=list) + shadowed_aliases: list[str] = field(default_factory=list) # those the store's agent of that id takes + package: str | None = None + package_version: str | None = None + bundle: str | None = None + + def origin(self) -> str: + if self.source == "store": + return f"the store, v{self.version}" + return f"{self.package} {self.package_version}, {self.bundle} bundle" + + +@click.command("list") +@click.option("--limit", default=_LIMIT, show_default=True, type=click.IntRange(min=0), + help="The most agents from the store to list, newest first; 0 lists them all") +@click.option("--json", "as_json", is_flag=True, help="Print the agents as JSON") +def list_agents(limit: int, as_json: bool): + """List the agents a task can name in a2a_agent_id: those the installed bundles ship, with the aliases that name + them, then those in the store, newest first.""" + bundles = installed_bundles() + aliases = installed_aliases(bundles) + agents, unreadable = _installed(bundles, aliases) + more, store_error = 0, None + try: + store = get_config().get_document_store() + for agent in agents: + agent.shadowed_aliases = [alias for alias in agent.aliases + if store.latest_version(A2A_AGENTS_COLLECTION, alias) is not None] + docs, total = store.latest_per_id_page(A2A_AGENTS_COLLECTION, Filter({}), sort=Sort.by("created_at_utc"), + limit=limit or None) + shipped = {agent.id for agent in agents} + agents += [_Agent(doc["id"], "store", version=doc.get("version")) for doc in docs if doc["id"] not in shipped] + more = total - len(docs) + except Exception as e: # each backend raises its own errors, and the installed agents are worth listing anyway + store_error = f"{type(e).__name__}: {e}" + if as_json: + click.echo(json.dumps({"agents": [asdict(agent) for agent in agents], "refused_aliases": dict(aliases.refused), + "more_in_store": more, "store_error": store_error, "unreadable_bundles": unreadable}, + indent=2)) + return + _print(agents, aliases, more, store_error, unreadable) + + +def _installed(bundles: tuple[InstalledBundle, ...], aliases: InstalledAliases) -> tuple[list[_Agent], int]: + """Each agent the installed bundles ship, and how many bundles can't be read.""" + agents, unreadable = [], 0 + for bundle in bundles: + try: + parsed = checked(bundle, load=False) if bundle.root is not None and not bundle.problem else None + except BundleError: + parsed = None + if parsed is None: + unreadable += 1 + continue + agents += [_Agent(entry.id, "installed", aliases=list(aliases.naming(entry.id)), package=bundle.package, + package_version=bundle.version, bundle=bundle.name) + for entry in parsed.entries if entry.kind is BundleKind.AGENT] + return agents, unreadable + + +def _print(agents: list[_Agent], aliases: InstalledAliases, more: int, store_error: str | None, + unreadable: int) -> None: + rows = [("ID", "ALIASES", "FROM")] + rows += [(agent.id, ", ".join(f"{alias} (shadowed)" if alias in agent.shadowed_aliases else alias + for alias in agent.aliases), agent.origin()) for agent in agents] + widths = [max(len(row[i]) for row in rows) for i in range(2)] + for row in rows: + click.echo(f"{row[0]:<{widths[0]}} {row[1]:<{widths[1]}} {row[2]}".rstrip()) + notes = ["A task names an agent in a2a_agent_id by its id, or by an alias."] + if any(agent.shadowed_aliases for agent in agents): + notes.append("A shadowed alias names the store's agent of that id instead.") + if more: + notes.append(f"The store has {more} more agent{'s' if more != 1 else ''}: --limit 0 lists them all.") + if store_error: + notes.append(f"The store can't be read, so only the installed agents are listed: {store_error}") + if unreadable: + notes.append(f"{unreadable} installed bundle{'s' if unreadable != 1 else ''} can't be read: agent-env run says " + "why.") + click.echo("\n" + "\n".join(notes)) + if aliases.refused: + click.echo("\nRefused aliases:\n" + "\n".join(f" {why}" for why in aliases.refused.values())) diff --git a/src/agent_env/cli/a2a_agent/validate.py b/src/agent_env/cli/a2a_agent/validate.py index f56f03dc..f72f776d 100644 --- a/src/agent_env/cli/a2a_agent/validate.py +++ b/src/agent_env/cli/a2a_agent/validate.py @@ -46,8 +46,8 @@ def validate(agent_id: str, agent_version: int | None, litellm_api_key: str | No try: agent = A2AAgent.get(agent_id, agent_version) - except NotFoundError: - click.echo(click.style(f"Error: A2AAgent '{agent_id}' not found", fg="red"), err=True) + except NotFoundError as e: + click.echo(click.style(f"Error: {e}", fg="red"), err=True) sys.exit(1) verifications = run_validation(agent, litellm_api_key=litellm_api_key) diff --git a/src/agent_env/cli/plugin.py b/src/agent_env/cli/plugin.py index ac6f76bd..4c1ebe00 100644 --- a/src/agent_env/cli/plugin.py +++ b/src/agent_env/cli/plugin.py @@ -35,7 +35,9 @@ ) from agent_env.plugins._cli import CLI_PLUGINS_GROUP, CLI_ROOT_OPTIONS_GROUP, cli_contributions, cli_discovery_errors from agent_env.plugins._discovery import discovery_error +from agent_env.plugins._bundles import InstalledAliases, installed_aliases from agent_env.plugins._report import ( + ALIAS_REFUSED, ENTRY_POINTS_UNREADABLE, FORMAT_VERSION, INVALID_PLUGIN, @@ -152,14 +154,17 @@ def _bundle_contributions(*, load: bool) -> tuple[dict[tuple[str, str], list[Con except Exception as e: return {}, {BUNDLES: Diagnostic(ENTRY_POINTS_UNREADABLE, discovery_error(e))} found: dict[tuple[str, str], list[Contribution]] = {} + aliases = installed_aliases(bundles) for bundle in bundles: - found.setdefault((bundle.package, bundle.version), []).append(_bundle_contribution(bundle, bundles, load=load)) + found.setdefault((bundle.package, bundle.version), []).append( + _bundle_contribution(bundle, bundles, aliases, load=load)) return found, {} -def _bundle_contribution(bundle: InstalledBundle, bundles: tuple[InstalledBundle, ...], *, load: bool) -> Contribution: - """Whether ``bundle`` runs, by the checks ``agent-env run`` makes before it reads a store. Without loading - plugins, only that the folder parses.""" +def _bundle_contribution(bundle: InstalledBundle, bundles: tuple[InstalledBundle, ...], aliases: InstalledAliases, *, + load: bool) -> Contribution: + """Whether ``bundle`` runs, by the checks ``agent-env run`` makes before it reads a store, and whether it gets each + alias it gives an agent. Without loading plugins, only that the folder parses.""" namesakes = tuple(Claimant(other.package, other.version, other.value) for other in bundles if other.name == bundle.name and other is not bundle) if bundle.problem: @@ -171,10 +176,13 @@ def _bundle_contribution(bundle: InstalledBundle, bundles: tuple[InstalledBundle except BundleError as e: return Contribution(BUNDLES, bundle.name, bundle.value, "failed", code=INVALID_PLUGIN, reason=f"it isn't a valid bundle: {e.summary}") + refused = aliases.refused_claims.get(bundle.qualified, ()) if run_name(bundle, bundles) != bundle.name: + qualified = f"another package also installs {bundle.name!r}, so run this one as {bundle.qualified!r}" return Contribution(BUNDLES, bundle.name, bundle.value, "active", code=QUALIFIED_ONLY, - reason=f"another package also installs {bundle.name!r}, so run this one as " - f"{bundle.qualified!r}", conflicts_with=namesakes) + reason="; ".join((qualified, *refused)), conflicts_with=namesakes) + if refused: + return Contribution(BUNDLES, bundle.name, bundle.value, "active", code=ALIAS_REFUSED, reason="; ".join(refused)) return Contribution(BUNDLES, bundle.name, bundle.value, "active") diff --git a/src/agent_env/cli/run.py b/src/agent_env/cli/run.py index efd16960..6c22099c 100644 --- a/src/agent_env/cli/run.py +++ b/src/agent_env/cli/run.py @@ -13,6 +13,7 @@ from agent_env.bundle import (BundleError, BundleKind, BundleRun, DryRun, Outcome, RunInterrupted, TaskRun, dry_run_bundle, run_bundle) from agent_env.bundle.installed import InstalledBundle, checked, find_bundle, installed_bundles, run_name +from agent_env.plugins._bundles import installed_aliases from agent_env.plugins._discovery import discovery_error from agent_env.providers.sandbox_providers.local_sandbox import LocalSandbox from agent_env.task.interrupts import Interrupts @@ -149,10 +150,13 @@ def echo(name: str, package: str, contents: str, description: str) -> None: click.echo(f"{name:<{widths[0]}} {package:<{widths[1]}} {contents:<{widths[2]}} {description}".rstrip()) echo(*header) + aliases = installed_aliases(bundles) for bundle, row in zip(bundles, rows): echo(*row) if bundle.root is not None: click.echo(f"{'':<{widths[0]}} {bundle.root}") + if named := aliases.of(bundle): + click.echo(f"{'':<{widths[0]}} aliases: {', '.join(named)}") click.echo("\nRun one with agent-env run NAME. To change one, copy its folder and run the copy.") diff --git a/src/agent_env/plugins/_bundles.py b/src/agent_env/plugins/_bundles.py new file mode 100644 index 00000000..27c0cfdd --- /dev/null +++ b/src/agent_env/plugins/_bundles.py @@ -0,0 +1,257 @@ +"""The bundles installed distributions register, through the ``agent_env.bundles`` entry points. + +A distribution registers `` = ""`` and ships the bundle folder at ``//``. 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//``. + +A bundle can give its agents short aliases in an ``aliases.toml`` at its root, which a reference names an agent by +when no agent of that id is in the store (``installed_aliases``). + +It imports nothing from ``agent_env.bundle``, so a task step can read what's installed without importing the bundle +machinery, whose run imports the task steps. +""" + +from __future__ import annotations + +import difflib +import importlib.util +import os +import re +import tomllib +from collections import Counter +from collections.abc import Mapping, Sequence +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.utils.paths import os_reason + +BUNDLES = "agent_env.bundles" +CORE = "agentenv-framework" +ALIASES_FILE = "aliases.toml" +# A plain id, never a bundle's namespaced one, which holds '@' and '/'. +_ALIAS = re.compile(r"[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?") +_ALIAS_RULE = "lowercase letters, digits and '-', at most 64 characters, starting and ending with a letter or digit" + + +@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) + + +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 ``/`` 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 + + +@dataclass(frozen=True) +class BundleAlias: + """An alias an installed bundle gives one of its agents.""" + + alias: str + agent_id: str # ``/`` + bundle: InstalledBundle + + @property + def declared_by(self) -> str: + return f"{self.bundle.dist}'s {self.bundle.name} bundle" + + +@dataclass(frozen=True) +class InstalledAliases: + """The aliases installed bundles give their agents: each alias that names an agent, each refused to every bundle + that declares it, with why, and each bundle's refused claims, with why.""" + + aliases: Mapping[str, BundleAlias] + refused: Mapping[str, str] + refused_claims: Mapping[str, tuple[str, ...]] # by the bundle's qualified name + + def target(self, alias: str) -> BundleAlias | None: + return self.aliases.get(alias) + + def refusal(self, alias: str) -> str | None: + return self.refused.get(alias) + + def of(self, bundle: InstalledBundle) -> tuple[str, ...]: + """The aliases that name ``bundle``'s agents, in name order.""" + return tuple(sorted(alias for alias, named in self.aliases.items() if named.bundle == bundle)) + + def naming(self, agent_id: str) -> tuple[str, ...]: + """The aliases that name the agent ``agent_id``, in name order.""" + return tuple(sorted(alias for alias, named in self.aliases.items() if named.agent_id == agent_id)) + + def hint(self, name: str) -> str: + """For ``name``, which names no agent: the aliases close to it, or ''.""" + close = difflib.get_close_matches(name, sorted(self.aliases), n=3) + if not close: + return "" + return (f"; did you mean the alias {' or '.join(repr(alias) for alias in close)}? agent-env a2a-agent list " + "lists the agents a task can name") + + +def read_aliases(path: Path) -> tuple[dict[str, tuple[str, ...]], list[str]]: + """The aliases a bundle's ``aliases.toml`` at ``path`` gives its agents, by agent folder name, and its problems; none + of either when there's no such file, and no aliases when it has a problem.""" + if not os.path.lexists(path): + return {}, [] + if not path.is_file(): + return {}, ["can't be read: it isn't a regular file"] + try: + data = tomllib.loads(path.read_text(encoding="utf-8")) + except OSError as e: + return {}, [f"can't be read: {os_reason(e)}"] + except (UnicodeDecodeError, tomllib.TOMLDecodeError) as e: + return {}, [f"isn't valid TOML: {e}"] + problems = [f"[{key}] isn't a table it takes; it takes [agents]" for key in data if key != "agents"] + agents = data.get("agents", {}) + if not isinstance(agents, dict): + return {}, [*problems, "[agents] must be a table of agent folder names to lists of aliases"] + found: dict[str, tuple[str, ...]] = {} + named: dict[str, str] = {} + for name, aliases in agents.items(): + if not (isinstance(aliases, list) and aliases and all(isinstance(alias, str) for alias in aliases)): + problems.append(f"agents.{name} must be a list of aliases, not {aliases!r}") + continue + for alias in aliases: + if not _ALIAS.fullmatch(alias): + problems.append(f"agents.{name}: {alias!r} isn't an alias: {_ALIAS_RULE}") + elif alias in named: + where = "twice" if named[alias] == name else f"for agents.{named[alias]} too" + problems.append(f"agents.{name}: {alias!r} is listed {where}; an alias names one agent") + else: + named[alias] = name + found[name] = tuple(aliases) + return ({}, problems) if problems else (found, []) + + +def installed_aliases(bundles: Sequence[InstalledBundle] | None = None) -> InstalledAliases: + """The aliases the installed bundles give their agents. agent-env's own bundles' aliases are theirs: another bundle + that declares one is refused it. An alias two other bundles both declare names neither, so a reference to it is + refused rather than given one of them. A bundle whose ``aliases.toml`` has a problem gives none, which checking the + bundle reports.""" + bundles = installed_bundles() if bundles is None else bundles + claims_by_alias: dict[str, list[BundleAlias]] = {} + for bundle in bundles: + if bundle.root is None or bundle.problem: + continue + declared, _ = read_aliases(bundle.root / ALIASES_FILE) + for name, aliases in declared.items(): + for alias in aliases: + claims_by_alias.setdefault(alias, []).append(BundleAlias(alias, f"{bundle.id_root}/{name}", bundle)) + aliases: dict[str, BundleAlias] = {} + refused: dict[str, str] = {} + refused_claims: dict[str, list[str]] = {} + for alias, claimants in sorted(claims_by_alias.items()): + core = [claim for claim in claimants if claim.bundle.dist == canonicalize_name(CORE)] + if core: + aliases[alias] = core[0] + for claim in claimants: + if claim.bundle.dist != core[0].bundle.dist: + refused_claims.setdefault(claim.bundle.qualified, []).append( + f"{alias!r} is agent-env's own alias, of {core[0].agent_id}, so this bundle can't give it") + elif len(claimants) == 1: + aliases[alias] = claimants[0] + else: + declared_by = " and ".join(claim.declared_by for claim in claimants) + refused[alias] = (f"{alias!r} is an alias {declared_by} each give an agent, so it names none of them; " + "name the agent by its id") + for claim in claimants: + refused_claims.setdefault(claim.bundle.qualified, []).append( + f"{alias!r} is another installed bundle's alias too, so neither gets it") + return InstalledAliases(aliases, refused, {name: tuple(why) for name, why in refused_claims.items()}) + diff --git a/src/agent_env/plugins/_report.py b/src/agent_env/plugins/_report.py index 050c9b37..4d561d1a 100644 --- a/src/agent_env/plugins/_report.py +++ b/src/agent_env/plugins/_report.py @@ -26,6 +26,7 @@ GROUP_BUILD_FAILED = "group-build-failed" ENTRY_POINTS_UNREADABLE = "entry-points-unreadable" QUALIFIED_ONLY = "qualified-only" +ALIAS_REFUSED = "alias-refused" # Where each code may appear: a contribution status, or "error" for a group, config or discovery error. CODES: dict[str, frozenset[str]] = { @@ -44,4 +45,5 @@ GROUP_BUILD_FAILED: frozenset({"blocked", "error"}), ENTRY_POINTS_UNREADABLE: frozenset({"error"}), QUALIFIED_ONLY: frozenset({"active"}), + ALIAS_REFUSED: frozenset({"active"}), } diff --git a/src/agent_env/utils/paths.py b/src/agent_env/utils/paths.py index 167a3d64..f678f44d 100644 --- a/src/agent_env/utils/paths.py +++ b/src/agent_env/utils/paths.py @@ -2,6 +2,7 @@ from __future__ import annotations +import errno import os # What the OS or Python writes into a folder on its own. In an artifact or skill folder, everything @@ -22,3 +23,12 @@ def validate_relative_filename(filename: str) -> None: parts = filename.replace("\\", "/").split("/") if any(p == ".." for p in parts): raise ValueError(f"Filename {filename!r} must not contain '..' segments") + + +def os_reason(e: OSError) -> str: + """What an OSError says went wrong, as a problem message names it.""" + 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) diff --git a/tst/integration/cli/run_bundle_claude_code_test.py b/tst/integration/cli/run_bundle_claude_code_test.py index 8d5448bf..7e8eeb2f 100644 --- a/tst/integration/cli/run_bundle_claude_code_test.py +++ b/tst/integration/cli/run_bundle_claude_code_test.py @@ -11,12 +11,16 @@ from click.testing import CliRunner from agent_env.a2a_agent import A2AAgent, protocol +from agent_env.a2a_agent.store import get_a2a_agent_instance_store from agent_env.artifact.store import reset_artifact_store +from agent_env.bundle import parse_bundle +from agent_env.bundle.installed import find_bundle from agent_env.cli import cli from agent_env.config import configure, get_config, reset_config from agent_env.providers.sandbox_providers.local_sandbox import _host_ips from agent_env.store.object_store.local.grant_server import grant_server from agent_env.store.routing import namespace_routing +from agent_env.task import Task from agent_env.task.teardown import teardown_run from agent_env.task_step.context import TaskStepContext from agent_env.task_step.task_step import TaskStep @@ -74,9 +78,10 @@ def _bundle(root, name, steps): return root -def _ask(prompt, needle, env_ids=()): +def _ask(prompt, needle, env_ids=(), agent_id=None): + named = {"a2a_agent_id": agent_id} if agent_id else {} return [ - {"id": "agent", "type": "deploy_agent", "env_ids": list(env_ids), "sandbox_type": "local"}, + {"id": "agent", "type": "deploy_agent", "env_ids": list(env_ids), "sandbox_type": "local", **named}, {"id": "ask", "type": "prompt_agent", "prompt_id": "ask", "prompt": prompt, "depends_on": ["agent"]}, {"id": "check", "type": "agent_prompt_response_verifier", "prompt_id": "ask", "verifier_id": "answer", "criteria": [{"type": "response_contains", "needles": [needle]}], "depends_on": ["ask"]}, @@ -108,6 +113,49 @@ def test_a_task_naming_no_agent_runs_the_shipped_claude_code_against_the_configu assert "tasks/ask.json v1: passed" in second.output, second.output +def _deployed(agent_id): + """The ids of the agents a deploy_agent step naming ``agent_id`` deploys, run outside a bundle.""" + context = TaskStepContext() + + async def run(): + try: + await DeployAgentTaskStep(id="agent", version=None, a2a_agent_id=agent_id, sandbox_type="local").execute( + context) + return [get_a2a_agent_instance_store().get(agent.instance_id).agent_id for agent in context.deployed_agents] + finally: + await teardown_run(context) + + with namespace_routing(): + return asyncio.run(run()) + + +def test_the_alias_claude_code_names_the_shipped_agent_until_the_store_has_an_agent_of_that_id(state, endpoint): + endpoint.respond = answer("pong") + shipped = get_config().get_default_a2a_agent_id() + aliased = _bundle(state / "aliased", "ask", _ask("Say pong.", "pong", agent_id="claude-code")) + + first = _run(aliased) + + assert first.exit_code == 0 and "tasks/ask.json v1: passed" in first.output, first.output + assert f"claude-code → {shipped} (the alias agentenv-framework's agents bundle gives it)" in first.output + assert _deployed("claude-code") == [shipped] + + put = CliRunner().invoke(cli, ["a2a-agent", "put", "--id", "claude-code", "--skip-validation", "--platform", "", + "--dockerfile", str(find_bundle("agents").root / "agents/claude-code/Dockerfile")]) + assert put.exit_code == 0, put.output + own = _bundle(state / "own", "ask", _ask("Say pong.", "pong", agent_id="claude-code")) + + second = _run(own) + + assert second.exit_code == 0 and "tasks/ask.json v1: passed" in second.output, second.output + assert "claude-code → the store's agent 'claude-code' v1, which shadows the alias" in second.output, second.output + assert _deployed("claude-code") == ["claude-code"] + with namespace_routing(): + named = [Task.get(f"{parse_bundle(root).id_root}/ask").to_dict()["steps"][0]["a2a_agent_id"] + for root in (aliased, own)] + assert named == [shipped, "claude-code"] + + def test_the_shipped_claude_code_calls_the_tools_of_the_env_it_is_deployed_with(state, endpoint): endpoint.respond = tool_then_answer("__echo", {"text": "ping-from-tool"}, prefix="the tool said: ") root = _bundle(state / "tools", "echo", [ diff --git a/tst/unit/a2a_agent/alias_get_test.py b/tst/unit/a2a_agent/alias_get_test.py new file mode 100644 index 00000000..271cbcac --- /dev/null +++ b/tst/unit/a2a_agent/alias_get_test.py @@ -0,0 +1,105 @@ +"""A2AAgent.get: the store's agent of an id, else the agent an installed bundle's alias of that id names.""" + +import pytest + +from agent_env.a2a_agent import A2AAgent +from agent_env.a2a_agent import a2a_agent as a2a_agent_module +from agent_env.artifact.artifacts.docker_image import DockerImageArtifact +from agent_env.artifact.store import get_artifact_store +from agent_env.plugins._bundles import ALIASES_FILE, InstalledBundle, installed_aliases +from agent_env.store.base import NotFoundError + +SHIPPED = "@local/agentenv-framework/agents/claude-code" + + +@pytest.fixture +def aliases(tmp_path, monkeypatch, local_stores, cli_routing): + """agent-env's agents bundle gives Claude Code the alias claude-code, and two plugin bundles both give helper.""" + bundles = [] + for package, name, toml in [("agentenv-framework", "agents", '[agents]\nclaude-code = ["claude-code"]\n'), + ("acme-kit", "kit", '[agents]\na = ["helper"]\n'), + ("zeta-tools", "tools", '[agents]\nb = ["helper"]\n')]: + root = tmp_path / package / name + root.mkdir(parents=True) + (root / ALIASES_FILE).write_text(toml) + bundles.append(InstalledBundle(name, package, "1.0", package, root, None, None)) + monkeypatch.setattr(a2a_agent_module, "installed_aliases", lambda: installed_aliases(bundles)) + + +def _put(agent_id): + image = get_artifact_store().put_document(DockerImageArtifact( + id=f"{agent_id}__agent_image", description="d", image_name="ghcr.io/acme/agent:1", tar_gz_s3_url="s3://b/x")) + return A2AAgent.put(id=agent_id, docker_image_artifact=image) + + +def test_an_alias_names_the_agent_its_bundle_gives_it(aliases): + _put(SHIPPED) + + assert A2AAgent.get("claude-code").id == SHIPPED + + +def test_the_stores_agent_of_an_aliass_id_is_the_one_it_names(aliases): + _put(SHIPPED) + _put("claude-code") + + assert A2AAgent.get("claude-code").id == "claude-code" + + +def test_an_id_that_is_no_alias_is_not_found_as_before(aliases): + with pytest.raises(NotFoundError) as e: + A2AAgent.get("nobody") + + assert "nobody" in str(e.value) + + +def test_an_alias_takes_no_version(aliases): + _put(SHIPPED) + + with pytest.raises(NotFoundError, match=f"'claude-code' is an alias of {SHIPPED}, which comes with agentenv-framework's " + "agents bundle, so it has no fixed version; drop the pin"): + A2AAgent.get("claude-code", 1) + + +def test_an_alias_whose_agent_no_run_has_written_says_so(aliases): + with pytest.raises(NotFoundError, match=f"'claude-code' is an alias of {SHIPPED}, which comes with " + "agentenv-framework's agents bundle and isn't in the store yet"): + A2AAgent.get("claude-code") + + +def test_an_alias_two_bundles_give_is_refused_naming_both(aliases): + with pytest.raises(NotFoundError, match="'helper' is an alias acme-kit's kit bundle and zeta-tools's tools bundle " + "each give an agent"): + A2AAgent.get("helper") + + +def test_a_version_the_stores_agent_of_an_alias_id_lacks_is_not_found(aliases): + _put(SHIPPED) + _put("claude-code") + + assert A2AAgent.get("claude-code", 1).id == "claude-code" + with pytest.raises(NotFoundError): + A2AAgent.get("claude-code", 2) + + +def test_the_stores_agent_of_a_refused_alias_id_is_the_one_it_names(aliases): + _put("helper") + + assert A2AAgent.get("helper").id == "helper" + + +def test_a_name_close_to_an_alias_is_pointed_at_it_unless_it_takes_a_version(aliases): + with pytest.raises(NotFoundError, match="there is no agent 'claude_code' in the store; did you mean the alias " + "'claude-code'"): + A2AAgent.get("claude_code") + with pytest.raises(NotFoundError) as pinned: + A2AAgent.get("claude_code", 2) + + assert "did you mean" not in str(pinned.value) + + +@pytest.mark.parametrize("agent_id", ["@local/acme/kit/solver", "acme/solver"]) +def test_an_id_that_cant_be_an_alias_is_missing_without_reading_the_installed_aliases(aliases, monkeypatch, agent_id): + monkeypatch.setattr(a2a_agent_module, "installed_aliases", lambda: pytest.fail("read the installed aliases")) + + with pytest.raises(NotFoundError, match=f"^A2AAgent {agent_id} not found$"): + A2AAgent.get(agent_id) diff --git a/tst/unit/bundle/aliases_test.py b/tst/unit/bundle/aliases_test.py new file mode 100644 index 00000000..8de828da --- /dev/null +++ b/tst/unit/bundle/aliases_test.py @@ -0,0 +1,176 @@ +"""Aliases installed bundles give their agents: the aliases.toml format, and the table of every installed bundle's.""" + +import os + +import pytest + +from agent_env.bundle import BundleError, parse_bundle +from agent_env.plugins._bundles import ALIASES_FILE, InstalledBundle, installed_aliases, read_aliases +from tst.unit.bundle._support import layout + + +def _bundle(tmp_path, name, package="agentenv-framework", aliases=None): + root = tmp_path / package / name + root.mkdir(parents=True) + if aliases is not None: + (root / ALIASES_FILE).write_text(aliases) + return InstalledBundle(name, package, "1.0", package.replace("-", "_"), root, None, None) + + +def _read(tmp_path, text): + (tmp_path / ALIASES_FILE).write_text(text) + return read_aliases(tmp_path / ALIASES_FILE) + + +def test_an_aliases_file_gives_each_agent_its_aliases(tmp_path): + assert _read(tmp_path, '[agents]\nclaude-code = ["claude-code", "claude"]\npi = ["pi"]\n') == ( + {"claude-code": ("claude-code", "claude"), "pi": ("pi",)}, []) + + +def test_a_bundle_without_an_aliases_file_gives_none(tmp_path): + assert read_aliases(tmp_path / ALIASES_FILE) == ({}, []) + + +@pytest.mark.parametrize("text, problem", [ + ('[agents]\nx = ["Claude"]\n', "agents.x: 'Claude' isn't an alias: lowercase letters"), + ('[agents]\nx = ["@local/a/b"]\n', "agents.x: '@local/a/b' isn't an alias"), + ('[agents]\nx = ["a/b"]\n', "agents.x: 'a/b' isn't an alias"), + ('[agents]\nx = ["-x"]\n', "agents.x: '-x' isn't an alias"), + ('[agents]\nx = ["' + "a" * 65 + '"]\n', "isn't an alias"), + ('[agents]\nx = ["helper\\n"]\n', "agents.x: 'helper\\n' isn't an alias"), + ('[agents]\nx = "x"\n', "agents.x must be a list of aliases, not 'x'"), + ('[agents]\nx = []\n', "agents.x must be a list of aliases, not []"), + ('[agents]\nx = ["a"]\ny = ["a"]\n', "agents.y: 'a' is listed for agents.x too; an alias names one agent"), + ('[agents]\nx = ["a", "a"]\n', "agents.x: 'a' is listed twice"), + ('[envs]\nx = ["a"]\n', "[envs] isn't a table it takes; it takes [agents]"), + ('agents = 3\n', "[agents] must be a table"), + ('[agents\n', "isn't valid TOML"), +], ids=["case", "namespaced", "slash", "edge", "long", "final-newline", "not-a-list", "empty", "two-agents", "twice", "other-table", + "not-a-table", "toml"]) +def test_an_aliases_file_with_a_problem_gives_no_aliases_and_says_why(tmp_path, text, problem): + aliases, problems = _read(tmp_path, text) + + assert aliases == {} and len(problems) == 1 and problem in problems[0] + + +@pytest.mark.parametrize("make", [lambda path: path.mkdir(), lambda path: path.symlink_to(path.with_name("gone")), + lambda path: os.mkfifo(path)], ids=["folder", "broken-symlink", "fifo"]) +def test_an_aliases_file_that_isnt_a_readable_file_gives_no_aliases_and_says_so(tmp_path, make): + make(tmp_path / ALIASES_FILE) + + aliases, [problem] = read_aliases(tmp_path / ALIASES_FILE) + + assert (aliases, problem) == ({}, "can't be read: it isn't a regular file") + + +def test_an_installed_bundles_alias_names_its_agent_under_the_bundles_id_root(tmp_path): + agents = _bundle(tmp_path, "agents", aliases='[agents]\nclaude-code = ["claude-code"]\n') + + table = installed_aliases((agents,)) + + named = table.target("claude-code") + assert (named.agent_id, named.bundle, named.declared_by) == ( + "@local/agentenv-framework/agents/claude-code", agents, "agentenv-framework's agents bundle") + assert table.of(agents) == ("claude-code",) and table.target("pi") is None and table.refusal("claude-code") is None + + +def test_agent_envs_own_aliases_are_refused_to_any_other_bundle(tmp_path): + ours = _bundle(tmp_path, "agents", aliases='[agents]\nclaude-code = ["claude-code"]\n') + theirs = _bundle(tmp_path, "kit", package="acme-kit", aliases='[agents]\nmine = ["claude-code", "mine"]\n') + + table = installed_aliases((theirs, ours)) + + assert table.target("claude-code").bundle == ours and table.target("mine").bundle == theirs + assert table.refused_claims == {"acme-kit/kit": ( + "'claude-code' is agent-env's own alias, of @local/agentenv-framework/agents/claude-code, so this bundle " + "can't give it",)} + + +def test_a_name_close_to_an_alias_is_pointed_at_it(tmp_path): + table = installed_aliases((_bundle(tmp_path, "agents", aliases='[agents]\nclaude-code = ["claude-code"]\n'),)) + + assert table.hint("claude_code") == ("; did you mean the alias 'claude-code'? agent-env a2a-agent list lists the " + "agents a task can name") + assert table.hint("my-own-agent") == "" + + +def test_an_alias_two_other_bundles_give_names_neither_and_says_both(tmp_path): + one = _bundle(tmp_path, "kit", package="acme-kit", aliases='[agents]\na = ["helper"]\n') + two = _bundle(tmp_path, "tools", package="zeta-tools", aliases='[agents]\nb = ["helper"]\n') + + table = installed_aliases((one, two)) + + assert table.target("helper") is None + assert table.refusal("helper") == ("'helper' is an alias acme-kit's kit bundle and zeta-tools's tools bundle each " + "give an agent, so it names none of them; name the agent by its id") + assert set(table.refused_claims) == {"acme-kit/kit", "zeta-tools/tools"} + + +def test_a_bundle_that_cant_run_or_whose_aliases_file_has_a_problem_gives_no_aliases(tmp_path): + broken = _bundle(tmp_path, "kit", package="acme-kit", aliases='[agents]\na = ["helper"]\n') + broken = InstalledBundle(broken.name, broken.package, broken.version, broken.value, broken.root, "invalid-plugin", + "it can't be read") + bad_file = _bundle(tmp_path, "tools", package="zeta-tools", aliases='[agents]\nb = ["Helper"]\n') + + assert installed_aliases((broken, bad_file)).aliases == {} + + +_AGENTS = {"agents/solver/Dockerfile": "FROM scratch\n", "agents/helper/Dockerfile": "FROM scratch\n", + "tasks/t.json": "[]"} + + +def _problems(root): + with pytest.raises(BundleError) as e: + parse_bundle(root, id_root="@local/acme-kit/kit") + return list(e.value.problems) + + +def test_a_bundle_gives_its_agents_the_aliases_its_aliases_file_lists(tmp_path): + root = layout(tmp_path / "kit", {**_AGENTS, ALIASES_FILE: '[agents]\nsolver = ["solver", "fixer"]\n'}) + + bundle = parse_bundle(root, id_root="@local/acme-kit/kit") + + assert bundle.aliases == {"solver": ("solver", "fixer")} and ALIASES_FILE not in map(str, bundle.ignored) + + +@pytest.mark.parametrize("aliases, problem", [ + ('[agents]\nnobody = ["x"]\n', "aliases.toml: agents.nobody: this bundle has no agent 'nobody'"), + ('[agents]\nsolver = ["helper"]\n', "aliases.toml: agents.solver: 'helper' is the agent 'helper''s name"), + ('[agents]\nsolver = ["Solver"]\n', "aliases.toml: agents.solver: 'Solver' isn't an alias"), +], ids=["no-such-agent", "another-agents-name", "syntax"]) +def test_an_aliases_file_that_doesnt_fit_the_bundles_agents_is_refused(tmp_path, aliases, problem): + root = layout(tmp_path / "kit", {**_AGENTS, ALIASES_FILE: aliases}) + + [found] = _problems(root) + + assert found.startswith(problem) + + +def test_an_alias_names_an_agent_by_its_folder_so_the_agent_keeps_its_folders_id(tmp_path): + root = layout(tmp_path / "kit", {**_AGENTS, "agents/solver/agent.toml": 'id = "@local/acme-kit/elsewhere"\n', + ALIASES_FILE: '[agents]\nsolver = ["solver"]\n'}) + + [found] = _problems(root) + + assert found == ("aliases.toml: agents.solver: the agent declares the id '@local/acme-kit/elsewhere', and an alias " + "names an agent by its folder, so it must keep the id @local/acme-kit/kit/solver") + + +def test_a_misspelled_aliases_file_is_refused_rather_than_ignored(tmp_path): + root = layout(tmp_path / "kit", {**_AGENTS, "Aliases.TOML": '[agents]\nsolver = ["solver"]\n'}) + + [found] = _problems(root) + + assert found == "Aliases.TOML: spell it aliases.toml, the file a bundle gives its agents' aliases in" + + +@pytest.mark.parametrize("make, problem", [ + (lambda path: path.mkdir(), "aliases.toml: must be a file, not a folder"), + (lambda path: path.symlink_to(path.with_name("gone")), "aliases.toml: a broken symlink"), + (lambda path: os.mkfifo(path), "aliases.toml: neither a regular file nor a folder"), +], ids=["folder", "broken-symlink", "fifo"]) +def test_an_aliases_file_that_isnt_a_file_is_refused_rather_than_ignored(tmp_path, make, problem): + root = layout(tmp_path / "kit", _AGENTS) + make(root / ALIASES_FILE) + + assert _problems(root) == [problem] diff --git a/tst/unit/bundle/installed_entities_test.py b/tst/unit/bundle/installed_entities_test.py index 26af5b58..a628a524 100644 --- a/tst/unit/bundle/installed_entities_test.py +++ b/tst/unit/bundle/installed_entities_test.py @@ -15,8 +15,13 @@ from agent_env.bundle.installed import InstalledBundle from agent_env.bundle.ledger import locked_ids from agent_env.bundle.materialize import materialize -from agent_env.bundle.plan import check_bundle, plan_bundle +from agent_env.a2a_agent import A2AAgent +from agent_env.artifact.artifacts.docker_image import DockerImageArtifact +from agent_env.artifact.store import get_artifact_store +from agent_env.bundle.plan import AliasUse, check_bundle, plan_bundle from agent_env.bundle.resolve import resolve_bundle +from agent_env.plugins._bundles import ALIASES_FILE +from agent_env.store.routing import namespace_routing from tst.unit.bundle._support import RefusingStore, layout, local_builds ROOT = "@local/~/triage" @@ -326,3 +331,187 @@ def test_a_run_locks_the_entities_it_pulls_in_as_it_does_its_own(bundle_dir, tmp holder.wait() assert SOLVER in locked_ids(plan) and waited == [False] + + +# Agents named by an alias an installed bundle gives them + +ALIASED = {ALIASES_FILE: '[agents]\nsolver = ["solver-x"]\n'} + + +def _store_agent(agent_id): + with namespace_routing(): + image = get_artifact_store().put_document(DockerImageArtifact( + id=f"{agent_id}-image", description="d", image_name="ghcr.io/acme/agent:1", tar_gz_s3_url="s3://b/x.tar.gz")) + return A2AAgent.put(id=agent_id, docker_image_artifact=image) + + +def _step(plan, step_id="agent"): + task = next(write for write in plan.writes if write.kind is BundleKind.TASK) + return next(step for step in task.source.config if step["id"] == step_id) + + +def test_an_agent_named_by_an_installed_alias_is_pulled_in_and_the_task_names_it_by_id(bundle_dir, tmp_path): + kit = _kit(tmp_path, ALIASED) + + plan = _plan(_task(bundle_dir, [{"id": "agent", "type": "deploy_agent", "a2a_agent_id": "solver-x"}]), kit) + + assert _writes(plan) == [(BundleKind.ARTIFACT, f"{SOLVER}__agent_image"), (BundleKind.AGENT, SOLVER), + (BundleKind.TASK, f"{ROOT}/t")] + assert _step(plan)["a2a_agent_id"] == SOLVER + assert plan.aliases == {"solver-x": AliasUse("solver-x", SOLVER, "demo-dist's kit bundle")} + assert plan.aliases["solver-x"].agent_id == SOLVER and plan.store_refs == () + + +def test_the_stores_agent_of_an_alias_id_shadows_the_alias(bundle_dir, tmp_path): + """Someone's own agent of that id is the one they mean.""" + kit = _kit(tmp_path, ALIASED) + _store_agent("solver-x") + + plan = _plan(_task(bundle_dir, [{"id": "agent", "type": "deploy_agent", "a2a_agent_id": "solver-x"}]), kit) + + assert _writes(plan) == [(BundleKind.TASK, f"{ROOT}/t")] and _step(plan)["a2a_agent_id"] == "solver-x" + assert plan.aliases["solver-x"] == AliasUse("solver-x", SOLVER, "demo-dist's kit bundle", store_version=1) + assert plan.aliases["solver-x"].agent_id == "solver-x" and [ref.id for ref in plan.store_refs] == ["solver-x"] + + +def test_a_bundles_own_agent_named_like_an_installed_alias_is_the_one_it_names(bundle_dir, tmp_path): + kit = _kit(tmp_path, ALIASED) + + plan = _plan(_task(bundle_dir, [{"id": "agent", "type": "deploy_agent", "a2a_agent_id": "solver-x"}], + {"agents/solver-x/Dockerfile": "FROM scratch\n"}), kit) + + assert _step(plan)["a2a_agent_id"] == f"{ROOT}/solver-x" and plan.aliases == {} + + +def test_every_field_naming_an_alias_in_a_task_is_renamed(bundle_dir, tmp_path): + kit = _kit(tmp_path, ALIASED) + + plan = _plan(_task(bundle_dir, [ + {"id": "agent", "type": "deploy_agent", "a2a_agent_id": "solver-x"}, + {"id": "p", "type": "prompt_agent", "prompt_id": "p", "prompt": "hi", "depends_on": ["agent"]}, + {"id": "v", **JUDGE, "judge_a2a_agent_id": "solver-x", "depends_on": ["p"]}, + ]), kit) + + assert _step(plan)["a2a_agent_id"] == _step(plan, "v")["judge_a2a_agent_id"] == SOLVER + + +def test_an_alias_takes_no_pin(bundle_dir, tmp_path): + kit = _kit(tmp_path, ALIASED) + resolved = _task(bundle_dir, [{"id": "agent", "type": "deploy_agent", "a2a_agent_id": "solver-x", + "a2a_agent_version": 2}]) + + assert _problems(resolved, kit) == ( + f"tasks/t.json: step 'agent': a2a_agent_id: 'solver-x' is an alias of {SOLVER}, which comes with demo-dist's " + "kit bundle, so it has no fixed version; drop the pin",) + + +def test_a_pinned_reference_to_the_stores_agent_of_an_alias_id_names_that_agent(bundle_dir, tmp_path): + kit = _kit(tmp_path, ALIASED) + _store_agent("solver-x") + + plan = _plan(_task(bundle_dir, [{"id": "agent", "type": "deploy_agent", "a2a_agent_id": "solver-x", + "a2a_agent_version": 1}]), kit) + + assert [(ref.id, ref.version) for ref in plan.store_refs] == [("solver-x", 1)] and plan.aliases == {} + + +def test_an_alias_two_installed_bundles_give_is_refused_naming_both(bundle_dir, tmp_path): + kit = _kit(tmp_path, ALIASED) + other = _installed(tmp_path / "site2" / "tools", {"agents/helper/Dockerfile": "FROM scratch\n", + ALIASES_FILE: '[agents]\nhelper = ["solver-x"]\n'}, + dist="zeta-tools", name="tools") + resolved = _task(bundle_dir, [{"id": "agent", "type": "deploy_agent", "a2a_agent_id": "solver-x"}]) + + [problem] = _problems(resolved, kit, other) + + assert problem == ("tasks/t.json: step 'agent': a2a_agent_id: 'solver-x' is an alias demo-dist's kit bundle and " + "zeta-tools's tools bundle each give an agent, so it names none of them; name the agent by its id") + + +def test_the_stores_agent_of_a_refused_alias_id_is_the_one_it_names(bundle_dir, tmp_path): + kit = _kit(tmp_path, ALIASED) + other = _installed(tmp_path / "site2" / "tools", {"agents/helper/Dockerfile": "FROM scratch\n", + ALIASES_FILE: '[agents]\nhelper = ["solver-x"]\n'}, + dist="zeta-tools", name="tools") + _store_agent("solver-x") + + plan = _plan(_task(bundle_dir, [{"id": "agent", "type": "deploy_agent", "a2a_agent_id": "solver-x"}]), kit, other) + + assert [ref.id for ref in plan.store_refs] == ["solver-x"] and plan.aliases == {} + + +def test_an_agent_misspelling_an_alias_is_pointed_at_it(bundle_dir, tmp_path): + kit = _kit(tmp_path, ALIASED) + resolved = _task(bundle_dir, [{"id": "agent", "type": "deploy_agent", "a2a_agent_id": "solver_x"}]) + + assert _problems(resolved, kit) == ( + "tasks/t.json: step 'agent': a2a_agent_id: there is no agent 'solver_x' in the store; did you mean the alias " + "'solver-x'? agent-env a2a-agent list lists the agents a task can name",) + + +def test_an_alias_whose_agent_its_bundle_lacks_is_refused_naming_the_bundle(bundle_dir, tmp_path): + kit = _kit(tmp_path, {ALIASES_FILE: '[agents]\nghost = ["ghost"]\n'}) + resolved = _task(bundle_dir, [{"id": "agent", "type": "deploy_agent", "a2a_agent_id": "ghost"}]) + + [problem] = _problems(resolved, kit) + + assert problem.startswith(f"tasks/t.json: step 'agent': a2a_agent_id: '{KIT}/ghost' comes with the installed " + "bundle demo-dist/kit, which can't be read: aliases.toml: agents.ghost: this bundle has " + "no agent 'ghost'") + + +def test_a_default_agent_set_to_an_alias_is_pulled_in_for_a_step_naming_none(bundle_dir, tmp_path, default_agent): + kit = _kit(tmp_path, ALIASED) + default_agent("solver-x") + + plan = _plan(_task(bundle_dir, [{"id": "agent", "type": "deploy_agent"}]), kit) + + assert (BundleKind.AGENT, SOLVER) in _writes(plan) and "a2a_agent_id" not in _step(plan) + assert plan.aliases == {"solver-x": AliasUse("solver-x", SOLVER, "demo-dist's kit bundle")} + + +def test_a_default_agent_set_to_an_alias_no_step_falls_back_to_isnt_read(bundle_dir, tmp_path, default_agent): + kit = _kit(tmp_path, ALIASED) + default_agent("solver-x") + + plan = _plan(_task(bundle_dir, [{"id": "agent", "type": "deploy_agent", "a2a_agent_id": SOLVER}]), kit) + + assert plan.aliases == {} + + +def test_checking_a_bundle_naming_an_alias_reads_no_store(bundle_dir, tmp_path, monkeypatch): + kit = _kit(tmp_path, ALIASED) + resolved = _task(bundle_dir, [{"id": "agent", "type": "deploy_agent", "a2a_agent_id": "solver-x"}]) + monkeypatch.setattr("agent_env.bundle.plan.STORE_GETTERS", RefusingStore()) + + check_bundle(resolved, installed=lambda: (kit,)) + + +def test_a_dry_run_says_which_agent_an_alias_names_and_preflights_an_aliased_default(bundle_dir, tmp_path, + default_agent, monkeypatch): + default_agent("solver-x") + kit = _kit(tmp_path, ALIASED) + monkeypatch.setattr(run_module, "installed_bundles", lambda: (kit,)) + monkeypatch.setattr(preflight_module, "docker_unreachable", lambda: None) + _task(bundle_dir, [{"id": "agent", "type": "deploy_agent"}]) + said = [] + + dry = dry_run_bundle(bundle_dir, sandbox="local", on_progress=said.append) + + assert said[0] == f"solver-x → {SOLVER} (the alias demo-dist's kit bundle gives it)" + assert [dry.path(entry) for entry in dry.runs] == ["tasks/t.json"] + + +def test_a_dry_run_says_when_the_store_shadows_an_alias(bundle_dir, tmp_path, monkeypatch): + kit = _kit(tmp_path, ALIASED) + _store_agent("solver-x") + monkeypatch.setattr(run_module, "installed_bundles", lambda: (kit,)) + monkeypatch.setattr(preflight_module, "docker_unreachable", lambda: None) + _task(bundle_dir, [{"id": "agent", "type": "deploy_agent", "a2a_agent_id": "solver-x"}]) + said = [] + + dry_run_bundle(bundle_dir, sandbox="local", on_progress=said.append) + + assert said[0] == (f"solver-x → the store's agent 'solver-x' v1, which shadows the alias demo-dist's kit bundle " + f"gives {SOLVER}") + diff --git a/tst/unit/bundle/installed_test.py b/tst/unit/bundle/installed_test.py index e5d23658..053bbe65 100644 --- a/tst/unit/bundle/installed_test.py +++ b/tst/unit/bundle/installed_test.py @@ -21,6 +21,7 @@ from agent_env.cli import cli from agent_env.config.runtime import Config from agent_env.plugins import _discovery +from agent_env.plugins._bundles import ALIASES_FILE from agent_env.plugins._report import INCOMPATIBLE_CORE, INVALID_PLUGIN, NAME_CONFLICT from agent_env.task_step.task_step import TaskStep from tst.unit.plugins_test import _Dist @@ -232,6 +233,46 @@ def test_the_cli_lists_the_installed_bundles_by_the_name_each_runs_by(site, monk assert lines[-1] == "Run one with agent-env run NAME. To change one, copy its folder and run the copy." +def test_the_cli_lists_each_bundles_aliases_and_the_plugin_report_says_which_a_bundle_is_refused(site, monkeypatch): + """agent-env's own bundles' aliases are theirs: another bundle that gives one doesn't get it.""" + folder = _package(site, "demo_bundles", ["ours", "theirs"]) + for name, aliases in [("ours", '["helper"]'), ("theirs", '["helper", "fixer"]')]: + (folder / name / "agents/solver").mkdir(parents=True) + (folder / name / "agents/solver/Dockerfile").write_text("FROM scratch\n") + (folder / name / ALIASES_FILE).write_text(f"[agents]\nsolver = {aliases}\n") + _registered(monkeypatch, _ep("ours", "demo_bundles", dist=CORE), _ep("theirs", "demo_bundles", dist="demo-a")) + + listing = CliRunner().invoke(cli, ["run"]).output.splitlines() + report = json.loads(CliRunner().invoke(cli, ["plugin", "list", "--json", "--no-load"]).output) + + assert listing[listing.index(next(line for line in listing if line.startswith("ours"))) + 2].strip() == ( + "aliases: helper") + assert listing[listing.index(next(line for line in listing if line.startswith("theirs"))) + 2].strip() == ( + "aliases: fixer") + [theirs] = [c for plugin in report["plugins"] for c in plugin["contributions"] + if c["group"] == BUNDLES and c["name"] == "theirs"] + assert (theirs["status"], theirs["code"], theirs["reason"]) == ( + "active", "alias-refused", + "'helper' is agent-env's own alias, of @local/agentenv-framework/ours/solver, so this bundle can't give it") + + +def test_a_bundle_run_by_its_qualified_name_says_which_aliases_it_is_refused_too(site, monkeypatch): + for package in ("demo_bundles_core", "demo_bundles_a"): + folder = _package(site, package, ["agents"]) + (folder / "agents/agents/solver").mkdir(parents=True) + (folder / "agents/agents/solver/Dockerfile").write_text("FROM scratch\n") + (folder / "agents" / ALIASES_FILE).write_text('[agents]\nsolver = ["helper"]\n') + _registered(monkeypatch, _ep("agents", "demo_bundles_core", dist=CORE), _ep("agents", "demo_bundles_a", dist="demo-a")) + + report = json.loads(CliRunner().invoke(cli, ["plugin", "list", "--json", "--no-load"]).output) + + [theirs] = [c for plugin in report["plugins"] if plugin["name"] == "demo-a" for c in plugin["contributions"] + if c["group"] == BUNDLES] + assert (theirs["code"], theirs["reason"]) == ( + "qualified-only", "another package also installs 'agents', so run this one as 'demo-a/agents'; 'helper' is " + "agent-env's own alias, of @local/agentenv-framework/agents/solver, so this bundle can't give it") + + def test_the_cli_says_so_when_no_bundles_are_installed_and_needs_a_bundle_for_its_options(monkeypatch): _registered(monkeypatch) diff --git a/tst/unit/bundle/shipped_bundles_test.py b/tst/unit/bundle/shipped_bundles_test.py index 1f2d190a..85513f8e 100644 --- a/tst/unit/bundle/shipped_bundles_test.py +++ b/tst/unit/bundle/shipped_bundles_test.py @@ -13,6 +13,7 @@ from agent_env.bundle.plan import check_bundle, plan_bundle from agent_env.bundle.resolve import resolve_bundle from agent_env.config import get_config +from agent_env.plugins._bundles import installed_aliases from tst.unit.bundle._support import layout @@ -35,6 +36,16 @@ def test_a_shipped_bundle_parses_resolves_and_passes_the_checks_a_run_makes(name check_bundle(resolve_bundle(parse_bundle(bundle.root, id_root=bundle.id_root)), installed=installed_bundles) +def test_the_shipped_aliases_are_agent_envs_alone_and_claude_code_names_the_default_agent(): + """Two shipped bundles giving one alias would leave it naming neither.""" + table = installed_aliases(tuple(_shipped().values())) + + assert table.refused == {} and table.refused_claims == {} + assert {alias: named.agent_id for alias, named in table.aliases.items()} == { + "claude-code": "@local/agentenv-framework/agents/claude-code", "pi": "@local/agentenv-framework/agents/pi"} + assert table.target("claude-code").agent_id == get_config().get_default_a2a_agent_id() + + def test_a_task_naming_no_agent_plans_with_the_shipped_default(tmp_path, monkeypatch, local_stores, cli_routing): monkeypatch.setenv("HOME", str(tmp_path)) layout(tmp_path / "bare", {"tasks/t.json": json.dumps([{"id": "agent", "type": "deploy_agent", "env_ids": []}])}) diff --git a/tst/unit/cli/a2a_agent_list_test.py b/tst/unit/cli/a2a_agent_list_test.py new file mode 100644 index 00000000..d4061b5c --- /dev/null +++ b/tst/unit/cli/a2a_agent_list_test.py @@ -0,0 +1,126 @@ +"""`agent-env a2a-agent list` and `get`: the agents a task can name, and the agent a name deploys.""" + +import importlib +import json + +import pytest +from click.testing import CliRunner + +from agent_env.a2a_agent import A2AAgent +from agent_env.a2a_agent import a2a_agent as a2a_agent_module +from agent_env.artifact.artifacts.docker_image import DockerImageArtifact +from agent_env.artifact.store import get_artifact_store +from agent_env.cli import cli +from agent_env.plugins._bundles import ALIASES_FILE, InstalledBundle, installed_aliases + +SHIPPED = "@local/agentenv-framework/agents/claude-code" +# The package exports each command under its module's name. +get_module = importlib.import_module("agent_env.cli.a2a_agent.get") +list_module = importlib.import_module("agent_env.cli.a2a_agent.list_agents") + + +def _bundle(tmp_path, package, name, agents, aliases): + root = tmp_path / package / name + for agent in agents: + (root / "agents" / agent).mkdir(parents=True) + (root / "agents" / agent / "Dockerfile").write_text("FROM scratch\n") + (root / ALIASES_FILE).write_text(aliases) + return InstalledBundle(name, package, "1.0", package.replace("-", "_"), root, None, None) + + +@pytest.fixture +def installed(tmp_path, monkeypatch, local_stores, cli_routing): + """agent-env's agents bundle, and two plugin bundles that both give an agent the alias helper.""" + bundles = ( + _bundle(tmp_path, "agentenv-framework", "agents", ["claude-code", "pi"], + '[agents]\nclaude-code = ["claude-code"]\npi = ["pi"]\n'), + _bundle(tmp_path, "acme-kit", "kit", ["solver"], '[agents]\nsolver = ["helper", "fixer"]\n'), + _bundle(tmp_path, "zeta-tools", "tools", ["helper"], '[agents]\nhelper = ["helper"]\n'), + ) + monkeypatch.setattr(list_module, "installed_bundles", lambda: bundles) + for module in (a2a_agent_module, get_module): + monkeypatch.setattr(module, "installed_aliases", lambda: installed_aliases(bundles)) + return bundles + + +def _put(agent_id): + image = get_artifact_store().put_document(DockerImageArtifact( + id=f"{agent_id}__agent_image", description="d", image_name="ghcr.io/acme/agent:1", tar_gz_s3_url="s3://b/x")) + return A2AAgent.put(id=agent_id, docker_image_artifact=image) + + +def _lines(*args): + result = CliRunner().invoke(cli, ["a2a-agent", *args]) + assert result.exit_code == 0, result.output + return [" ".join(line.split()) for line in result.output.splitlines()] + + +def test_the_list_shows_each_installed_agent_with_its_aliases_then_the_stores_agents_newest_first(installed): + for agent_id in ("older", "newer", SHIPPED): # a bundle run writes the shipped agent it deploys + _put(agent_id) + + assert _lines("list") == [ + "ID ALIASES FROM", + f"{SHIPPED} claude-code agentenv-framework 1.0, agents bundle", + "@local/agentenv-framework/agents/pi pi agentenv-framework 1.0, agents bundle", + "@local/acme-kit/kit/solver fixer acme-kit 1.0, kit bundle", + "@local/zeta-tools/tools/helper zeta-tools 1.0, tools bundle", + "newer the store, v1", + "older the store, v1", + "", + "A task names an agent in a2a_agent_id by its id, or by an alias.", + "", + "Refused aliases:", + "'helper' is an alias acme-kit's kit bundle and zeta-tools's tools bundle each give an agent, so it names none " + "of them; name the agent by its id", + ] + + +def test_get_prints_the_agent_a_name_deploys_and_the_stores_agent_of_an_alias_id_shadows_the_alias(installed): + _put(SHIPPED) + + aliased = CliRunner().invoke(cli, ["a2a-agent", "get", "--id", "claude-code"]) + + assert aliased.exit_code == 0, aliased.output + assert json.loads(aliased.stdout)["id"] == SHIPPED + assert aliased.stderr == f"claude-code → {SHIPPED} (the alias agentenv-framework's agents bundle gives it)\n" + + _put("claude-code") + own = CliRunner().invoke(cli, ["a2a-agent", "get", "--id", "claude-code"]) + listed = json.loads(CliRunner().invoke(cli, ["a2a-agent", "list", "--json"]).output) + + assert (json.loads(own.stdout)["id"], own.stderr) == ("claude-code", "") + assert [(a["id"], a["shadowed_aliases"]) for a in listed["agents"] if a["shadowed_aliases"]] == [ + (SHIPPED, ["claude-code"])] + assert f"{SHIPPED} claude-code (shadowed) agentenv-framework 1.0, agents bundle" in _lines("list") + assert "A shadowed alias names the store's agent of that id instead." in _lines("list") + + +def test_the_list_takes_a_limit_on_the_store_and_says_how_many_more_it_has(installed): + for i in range(3): + _put(f"agent-{i}") + + limited = json.loads(CliRunner().invoke(cli, ["a2a-agent", "list", "--limit", "2", "--json"]).output) + everything = json.loads(CliRunner().invoke(cli, ["a2a-agent", "list", "--limit", "0", "--json"]).output) + + assert [a["id"] for a in limited["agents"] if a["source"] == "store"] == ["agent-2", "agent-1"] + assert limited["more_in_store"] == 1 and everything["more_in_store"] == 0 + assert [a["id"] for a in everything["agents"] if a["source"] == "store"] == ["agent-2", "agent-1", "agent-0"] + assert "The store has 1 more agent: --limit 0 lists them all." in _lines("list", "--limit", "2") + + +def test_the_list_shows_the_installed_agents_when_the_store_or_a_bundle_cant_be_read(installed, monkeypatch): + broken = InstalledBundle("broken", "demo-a", "1.0", "demo_a", None, "invalid-plugin", "it's broken") + monkeypatch.setattr(list_module, "installed_bundles", lambda: (*installed, broken)) + + class _Down: + def get_document_store(self): + raise ConnectionError("the store is down") + + monkeypatch.setattr(list_module, "get_config", lambda: _Down()) + + lines = _lines("list") + + assert f"{SHIPPED} claude-code agentenv-framework 1.0, agents bundle" in lines + assert "The store can't be read, so only the installed agents are listed: ConnectionError: the store is down" in lines + assert "1 installed bundle can't be read: agent-env run says why." in lines diff --git a/tst/unit/cli/plugin_report_test.py b/tst/unit/cli/plugin_report_test.py index 26c2b9ef..5d5b74df 100644 --- a/tst/unit/cli/plugin_report_test.py +++ b/tst/unit/cli/plugin_report_test.py @@ -308,6 +308,8 @@ def test_every_code_is_produced_by_some_scenario(monkeypatch, tmp_path): # From test_a_config_problem_is_a_group_error_with_its_code, and one the inventory cannot reach # while every registry records what it did (plugin_inventory_test covers it by forcing it). produced |= {"config-unreadable", "config-invalid", "group-build-failed", "status-unknown"} + # From bundle/installed_test's test_the_cli_lists_each_bundles_aliases_and_the_plugin_report_says_which_a_bundle_is_refused. + produced |= {"alias-refused"} assert produced - {None} == set(CODES) diff --git a/tst/unit/task_step/unwritten_local_agent_test.py b/tst/unit/task_step/unwritten_local_agent_test.py index 49cdb3a2..724ca1a6 100644 --- a/tst/unit/task_step/unwritten_local_agent_test.py +++ b/tst/unit/task_step/unwritten_local_agent_test.py @@ -1,12 +1,15 @@ """A step deploying an @local agent the store doesn't hold says that a bundle run writes one, not only that it isn't -found: outside a bundle run, nothing has written it.""" +found: outside a bundle run, nothing has written it. A step naming an agent by an installed bundle's alias deploys, or +checks, the agent the alias names.""" import pytest -from agent_env.a2a_agent import A2AAgent +from agent_env.a2a_agent.store import A2AAgentStore from agent_env.store.base import NotFoundError -from agent_env.task_step.context import PromptResponse, TaskStepContext +from agent_env.task_step.context import DeployedAgent, PromptResponse, TaskStepContext +from agent_env.task_step.task_steps.a2a_agent_validator.verify_a2a_agent_card import VerifyA2AAgentCardStep from agent_env.task_step.task_steps.deploy_agent import DeployAgentTaskStep +from agent_env.task_step.task_steps.install_agent import InstallAgentTaskStep from agent_env.task_step.task_steps.verifiers.rubrics_verifier import RubricsVerifierTaskStep AGENT = "@local/agentenv-framework/agents/claude-code" @@ -16,10 +19,10 @@ @pytest.fixture(autouse=True) def empty_store(monkeypatch): - def missing(cls, id, version=None): + def missing(self, id, version=None): raise NotFoundError(f"A2AAgent {id} not found") - monkeypatch.setattr(A2AAgent, "get", classmethod(missing)) + monkeypatch.setattr(A2AAgentStore, "get", missing) @pytest.mark.asyncio @@ -47,3 +50,51 @@ async def test_a_judge_deploying_an_unwritten_local_agent_names_the_run_that_wri with pytest.raises(NotFoundError, match=WRITTEN_BY_A_RUN + r".*point judge_a2a_agent_id"): await step.execute(context) + + +class _Looked(Exception): + """The store was asked for the agent an alias names.""" + + +@pytest.fixture +def aliased_store(monkeypatch): + """A store without an agent of the alias's id, whose lookup of the agent the alias names is recorded.""" + def get(self, id, version=None): + if id == AGENT: + raise _Looked(id) + raise NotFoundError(f"A2AAgent {id} not found") + + monkeypatch.setattr(A2AAgentStore, "get", get) + + +@pytest.mark.asyncio +async def test_a_step_naming_the_shipped_alias_deploys_the_agent_it_names(aliased_store): + with pytest.raises(_Looked): + await DeployAgentTaskStep(id="deploy", version=None, a2a_agent_id="claude-code").execute(TaskStepContext()) + + +def test_installing_the_shipped_alias_checks_the_agent_it_names(aliased_store): + with pytest.raises(_Looked): + InstallAgentTaskStep(id="install", version=None, sandbox_name="box", a2a_agent_id="claude-code").preflight() + + +@pytest.mark.asyncio +async def test_a_judge_named_by_the_shipped_alias_is_the_agent_it_names(aliased_store): + step = RubricsVerifierTaskStep(id="judge", version=1, criteria=[{"id": "done", "description": "done"}], + prompt_id="p1", verifier_id="v", use_trajectory=False, judge_a2a_agent_id="claude-code") + context = TaskStepContext( + prompt_responses=[PromptResponse(prompt_id="p1", response="done", prompt_text="do it")], + metadata={"user_overrides": {"judge_litellm_api_key": "k", "judge_litellm_base_url": "http://litellm"}}, + ) + + with pytest.raises(_Looked): + await step.execute(context) + + +@pytest.mark.asyncio +async def test_a_validator_step_naming_the_shipped_alias_checks_the_agent_it_names(aliased_store): + deployed = DeployedAgent(agent_name="agent", api_url="http://agent", a2a_card={"name": "claude-code"}) + + with pytest.raises(_Looked): + await VerifyA2AAgentCardStep(id="card", version=None, a2a_agent_id="claude-code").execute( + TaskStepContext(deployed_agents=[deployed]))