From 47352cd60bed03b6def35238ebc271a3cc510e9f Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Wed, 21 Oct 2026 23:42:06 -0700 Subject: [PATCH 1/6] feat(bundle): name bundled agents by alias, as claude-code for the shipped Claude Code A bundle task writing "a2a_agent_id": "claude-code" was refused unless the store held an agent of that id: the shipped Claude Code is @local/agentenv-framework/agents/claude-code. A bundle can now give its agents aliases in an aliases.toml at its root, and agent-env's agents bundle names Claude Code claude-code (and pi, pi). - A reference to an alias names the agent its installed 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; an alias takes no pin. A configured default that's an alias is resolved the same way. - A step deploying, installing or judging with an agent outside a bundle run looks it up with A2AAgent.resolve: the store's agent of that id, else the alias's agent. - agent-env's own bundles' aliases are theirs; an alias two other bundles give is refused, naming both. The run and the dry run say which agent each alias names, `agent-env run` lists each bundle's aliases, and `agent-env plugin list` reports a refused claim (alias-refused). - The installed-bundle discovery moves to agent_env/plugins/_bundles.py, which imports nothing from agent_env.bundle, so a task step can read the aliases without the import cycle the bundle package's run would make; os_reason moves to agent_env/utils/paths.py with it. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 6 + src/agent_env/a2a_agent/a2a_agent.py | 24 ++ src/agent_env/agents/README.md | 4 +- src/agent_env/agents/aliases.toml | 4 + src/agent_env/bundle/_fs.py | 11 +- src/agent_env/bundle/authoring.py | 4 +- src/agent_env/bundle/importer.py | 3 +- src/agent_env/bundle/installed.py | 123 +-------- src/agent_env/bundle/parse.py | 41 ++- src/agent_env/bundle/plan.py | 128 ++++++++- src/agent_env/bundle/preflight.py | 9 +- src/agent_env/bundle/run.py | 14 +- src/agent_env/cli/plugin.py | 15 +- src/agent_env/cli/run.py | 4 + src/agent_env/plugins/_bundles.py | 242 ++++++++++++++++++ src/agent_env/plugins/_report.py | 2 + .../task_step/task_steps/deploy_agent.py | 2 +- .../task_step/task_steps/install_agent.py | 4 +- .../task_steps/verifiers/rubrics_verifier.py | 2 +- src/agent_env/utils/paths.py | 10 + tst/unit/a2a_agent/resolve_test.py | 72 ++++++ tst/unit/bundle/aliases_test.py | 143 +++++++++++ tst/unit/bundle/installed_entities_test.py | 160 +++++++++++- tst/unit/bundle/installed_test.py | 24 ++ tst/unit/bundle/shipped_bundles_test.py | 11 + tst/unit/cli/plugin_report_test.py | 2 + .../task_step/unwritten_local_agent_test.py | 44 +++- 27 files changed, 955 insertions(+), 153 deletions(-) create mode 100644 src/agent_env/agents/aliases.toml create mode 100644 src/agent_env/plugins/_bundles.py create mode 100644 tst/unit/a2a_agent/resolve_test.py create mode 100644 tst/unit/bundle/aliases_test.py diff --git a/AGENTS.md b/AGENTS.md index 240e6413..24cd5863 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -147,6 +147,12 @@ 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 looks one up through `A2AAgent.resolve`, store first. agent-env's own bundles' aliases are + theirs, and an alias two other bundles give is refused, naming both. - 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..f5b6158d 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, @@ -401,6 +403,28 @@ def get(cls, id: str, version: Optional[int] = None) -> A2AAgent: from .store import get_a2a_agent_store return get_a2a_agent_store().get(id, version) + @classmethod + def resolve(cls, id: str, version: Optional[int] = None) -> A2AAgent: + """The agent ``id`` names: the store's of that id, else the one an installed bundle gives the alias ``id``, + which the store holds once a run of a bundle has used it. Raises NotFoundError, and ValueError for an alias with + a version: it means the agent its bundle ships.""" + try: + return cls.get(id, version) + except NotFoundError: + aliases = installed_aliases() + if (named := aliases.target(id)) is None: + if (refusal := aliases.refusal(id)) is not None: + raise NotFoundError(refusal) from None + raise + if version is not None: + raise ValueError(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") + try: + return cls.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: from .store import get_a2a_agent_store diff --git a/src/agent_env/agents/README.md b/src/agent_env/agents/README.md index 4b88aa0c..2105dfbc 100644 --- a/src/agent_env/agents/README.md +++ b/src/agent_env/agents/README.md @@ -3,7 +3,9 @@ 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. 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..c7e29979 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,10 @@ 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: + self.aliases_file = Path(entry.path) + 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..9443f381 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 @@ -27,7 +30,7 @@ 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 +39,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, @@ -100,6 +104,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 +142,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.""" @@ -210,22 +231,87 @@ 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, default = installed_aliases(self.installed()), {}, _UNREAD + 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) + 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) + 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]) -> AliasUse | str | None: + """What ``agent_id`` names when it's an installed alias, the store's agent of that id read first, recorded in + ``uses``; why it can't be named so; or None when it's no alias.""" + if agent_id.startswith(LOCAL_PREFIX): + return None + if (refusal := table.refusal(agent_id)) is not None: + return refusal + if (named := table.target(agent_id)) is None: + return None + 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") + if agent_id not in uses: + try: + store_version = STORE_GETTERS[EntityKind.AGENT](agent_id, None).version + except NotFoundError: + store_version = None + except (ValueError, KeyError, TypeError): # one the store has but can't read, which the store ref check says + return None + uses[agent_id] = AliasUse(agent_id, named.agent_id, named.declared_by, store_version) + return uses[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 +392,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 +409,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() @@ -881,6 +968,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/plugin.py b/src/agent_env/cli/plugin.py index ac6f76bd..c5909378 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: @@ -175,6 +180,8 @@ def _bundle_contribution(bundle: InstalledBundle, bundles: tuple[InstalledBundle 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) + if refused := aliases.refused_claims.get(bundle.qualified): + 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..811acb59 --- /dev/null +++ b/src/agent_env/plugins/_bundles.py @@ -0,0 +1,242 @@ +"""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 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 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 path.is_file(): + return {}, [] + 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.match(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/task_step/task_steps/deploy_agent.py b/src/agent_env/task_step/task_steps/deploy_agent.py index f2b3728b..ae93032a 100644 --- a/src/agent_env/task_step/task_steps/deploy_agent.py +++ b/src/agent_env/task_step/task_steps/deploy_agent.py @@ -357,7 +357,7 @@ async def _execute(self, context: TaskStepContext, deployed_agents: list) -> Tas user_overrides = context.metadata.get("user_overrides", {}) a2a_agent_id, a2a_agent_version = self._resolve_agent_ref(context) try: - agent = A2AAgent.get(a2a_agent_id, a2a_agent_version) + agent = A2AAgent.resolve(a2a_agent_id, a2a_agent_version) except NotFoundError: if not is_local_id(a2a_agent_id): raise diff --git a/src/agent_env/task_step/task_steps/install_agent.py b/src/agent_env/task_step/task_steps/install_agent.py index 40df1eb2..0aa4259a 100644 --- a/src/agent_env/task_step/task_steps/install_agent.py +++ b/src/agent_env/task_step/task_steps/install_agent.py @@ -171,7 +171,7 @@ def preflight(self) -> list[str]: run, whose ``a2a_agent_id`` override can name another, so a task saved naming a placeholder still saves. A step naming no agent installs the configured default.""" try: - agent = A2AAgent.get(self.a2a_agent_id or get_config().get_default_a2a_agent_id(), self.a2a_agent_version) + agent = A2AAgent.resolve(self.a2a_agent_id or get_config().get_default_a2a_agent_id(), self.a2a_agent_version) except (NotFoundError, ValueError, KeyError, TypeError): return [] problem = _install_problem(agent) @@ -222,7 +222,7 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: # caller swap which agent gets installed without rewriting the task definition. user_overrides = context.metadata.get("user_overrides", {}) a2a_agent_id = user_overrides.get("a2a_agent_id") or self.a2a_agent_id or get_config().get_default_a2a_agent_id() - agent = A2AAgent.get(a2a_agent_id, self.a2a_agent_version) + agent = A2AAgent.resolve(a2a_agent_id, self.a2a_agent_version) if problem := _install_problem(agent): raise RuntimeError(problem) diff --git a/src/agent_env/task_step/task_steps/verifiers/rubrics_verifier.py b/src/agent_env/task_step/task_steps/verifiers/rubrics_verifier.py index e908bb61..a1e3d786 100644 --- a/src/agent_env/task_step/task_steps/verifiers/rubrics_verifier.py +++ b/src/agent_env/task_step/task_steps/verifiers/rubrics_verifier.py @@ -766,7 +766,7 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: env_vars = {"LITELLM_API_KEY": api_key, "LITELLM_BASE_URL": litellm_base_url} resolved_judge_id = self.judge_a2a_agent_id or config.get_default_a2a_agent_id() try: - a2a_agent = A2AAgent.get(resolved_judge_id) + a2a_agent = A2AAgent.resolve(resolved_judge_id) except NotFoundError: if not is_local_id(resolved_judge_id): raise 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/unit/a2a_agent/resolve_test.py b/tst/unit/a2a_agent/resolve_test.py new file mode 100644 index 00000000..71076471 --- /dev/null +++ b/tst/unit/a2a_agent/resolve_test.py @@ -0,0 +1,72 @@ +"""An agent looked up as a step deploys it: the store's of that id, else the agent an installed bundle's alias 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.resolve("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.resolve("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.resolve("nobody") + + assert "nobody" in str(e.value) + + +def test_an_alias_takes_no_version(aliases): + _put(SHIPPED) + + with pytest.raises(ValueError, 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.resolve("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.resolve("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.resolve("helper") diff --git a/tst/unit/bundle/aliases_test.py b/tst/unit/bundle/aliases_test.py new file mode 100644 index 00000000..32eeedfc --- /dev/null +++ b/tst/unit/bundle/aliases_test.py @@ -0,0 +1,143 @@ +"""Aliases installed bundles give their agents: the aliases.toml format, and the table of every installed bundle's.""" + +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 = "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", "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] + + +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_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" diff --git a/tst/unit/bundle/installed_entities_test.py b/tst/unit/bundle/installed_entities_test.py index 26af5b58..5b81202a 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,156 @@ 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_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_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..e55922f7 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,29 @@ 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_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/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..1c43e3d3 100644 --- a/tst/unit/task_step/unwritten_local_agent_test.py +++ b/tst/unit/task_step/unwritten_local_agent_test.py @@ -1,5 +1,6 @@ """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 the +agent the alias names.""" import pytest @@ -7,6 +8,7 @@ from agent_env.store.base import NotFoundError from agent_env.task_step.context import PromptResponse, TaskStepContext 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" @@ -47,3 +49,43 @@ 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(cls, id, version=None): + if id == AGENT: + raise _Looked(id) + raise NotFoundError(f"A2AAgent {id} not found") + + monkeypatch.setattr(A2AAgent, "get", classmethod(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) + From 786c6bf3bf1f937ded2d0dcda7e8fba45415055f Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Wed, 21 Oct 2026 23:56:27 -0700 Subject: [PATCH 2/6] fix(bundle): the store's agent of an alias's id comes before the alias's rules A pinned reference to a stored agent of an alias's id, or one whose alias two bundles clash on, now names that agent instead of being refused, at plan time and at run time. An aliases.toml that's a folder or a broken symlink is refused rather than ignored, an alias can't end in a newline, and a bundle run by its qualified name still says which aliases it's refused. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/agent_env/a2a_agent/a2a_agent.py | 18 +++++++--- src/agent_env/bundle/parse.py | 6 +++- src/agent_env/bundle/plan.py | 39 ++++++++++++---------- src/agent_env/cli/plugin.py | 7 ++-- src/agent_env/plugins/_bundles.py | 6 ++-- tst/unit/a2a_agent/resolve_test.py | 15 +++++++++ tst/unit/bundle/aliases_test.py | 24 ++++++++++++- tst/unit/bundle/installed_entities_test.py | 22 ++++++++++++ tst/unit/bundle/installed_test.py | 17 ++++++++++ 9 files changed, 124 insertions(+), 30 deletions(-) diff --git a/src/agent_env/a2a_agent/a2a_agent.py b/src/agent_env/a2a_agent/a2a_agent.py index f5b6158d..569c68c0 100644 --- a/src/agent_env/a2a_agent/a2a_agent.py +++ b/src/agent_env/a2a_agent/a2a_agent.py @@ -407,15 +407,17 @@ def get(cls, id: str, version: Optional[int] = None) -> A2AAgent: def resolve(cls, id: str, version: Optional[int] = None) -> A2AAgent: """The agent ``id`` names: the store's of that id, else the one an installed bundle gives the alias ``id``, which the store holds once a run of a bundle has used it. Raises NotFoundError, and ValueError for an alias with - a version: it means the agent its bundle ships.""" + a version: it means the agent its bundle ships. A pinned version the store's agent of that id lacks is just + missing.""" try: return cls.get(id, version) except NotFoundError: aliases = installed_aliases() - if (named := aliases.target(id)) is None: - if (refusal := aliases.refusal(id)) is not None: - raise NotFoundError(refusal) from None + named, refusal = aliases.target(id), aliases.refusal(id) + if named is None and refusal is None or version is not None and cls._stored(id): raise + if named is None: + raise NotFoundError(refusal) from None if version is not None: raise ValueError(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") @@ -425,6 +427,14 @@ def resolve(cls, id: str, version: Optional[int] = None) -> A2AAgent: 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 _stored(cls, id: str) -> bool: + try: + cls.get(id) + except NotFoundError: + return False + return True + @classmethod def put(cls, **kwargs) -> A2AAgent: from .store import get_a2a_agent_store diff --git a/src/agent_env/bundle/parse.py b/src/agent_env/bundle/parse.py index c7e29979..4784e14b 100644 --- a/src/agent_env/bundle/parse.py +++ b/src/agent_env/bundle/parse.py @@ -211,7 +211,11 @@ def _scan_root(self) -> tuple[dict[BundleKind, Path], str | None]: self._problem(entry.path, "reserved: a bundle takes its name from the folder and its " "description from README.md") elif entry.name == ALIASES_FILE: - self.aliases_file = Path(entry.path) + 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: diff --git a/src/agent_env/bundle/plan.py b/src/agent_env/bundle/plan.py index 9443f381..ff31e171 100644 --- a/src/agent_env/bundle/plan.py +++ b/src/agent_env/bundle/plan.py @@ -257,7 +257,7 @@ def _resolve_aliases(self) -> dict[str, AliasUse]: pointed at the alias's agent is left with the problem the store ref check reports.""" if self.installed is None: return {} - table, uses, default = installed_aliases(self.installed()), {}, _UNREAD + table, uses, stored, default = installed_aliases(self.installed()), {}, {}, _UNREAD for index, entry in enumerate(self.entries): if entry.entry.kind is not BundleKind.TASK: continue @@ -265,7 +265,7 @@ def _resolve_aliases(self) -> dict[str, AliasUse]: 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) + 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: @@ -274,7 +274,7 @@ def _resolve_aliases(self) -> dict[str, AliasUse]: 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) + 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: @@ -283,28 +283,31 @@ def _resolve_aliases(self) -> dict[str, AliasUse]: 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]) -> AliasUse | str | None: - """What ``agent_id`` names when it's an installed alias, the store's agent of that id read first, recorded in - ``uses``; why it can't be named so; or None when it's no alias.""" + 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 - if (refusal := table.refusal(agent_id)) is not None: - return refusal - if (named := table.target(agent_id)) is None: + refusal, named = table.refusal(agent_id), table.target(agent_id) + if refusal is None and named is None: return None - 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") - if agent_id not in uses: + if agent_id not in stored: try: - store_version = STORE_GETTERS[EntityKind.AGENT](agent_id, None).version + stored[agent_id] = STORE_GETTERS[EntityKind.AGENT](agent_id, None).version except NotFoundError: - store_version = None + stored[agent_id] = None except (ValueError, KeyError, TypeError): # one the store has but can't read, which the store ref check says return None - uses[agent_id] = AliasUse(agent_id, named.agent_id, named.declared_by, store_version) - return uses[agent_id] + 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.""" diff --git a/src/agent_env/cli/plugin.py b/src/agent_env/cli/plugin.py index c5909378..4c1ebe00 100644 --- a/src/agent_env/cli/plugin.py +++ b/src/agent_env/cli/plugin.py @@ -176,11 +176,12 @@ 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) - if refused := aliases.refused_claims.get(bundle.qualified): + 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/plugins/_bundles.py b/src/agent_env/plugins/_bundles.py index 811acb59..333d03e1 100644 --- a/src/agent_env/plugins/_bundles.py +++ b/src/agent_env/plugins/_bundles.py @@ -35,7 +35,7 @@ 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 = 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" @@ -174,7 +174,7 @@ def of(self, bundle: InstalledBundle) -> tuple[str, ...]: 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 path.is_file(): + if not os.path.lexists(path): return {}, [] try: data = tomllib.loads(path.read_text(encoding="utf-8")) @@ -193,7 +193,7 @@ def read_aliases(path: Path) -> tuple[dict[str, tuple[str, ...]], list[str]]: problems.append(f"agents.{name} must be a list of aliases, not {aliases!r}") continue for alias in aliases: - if not _ALIAS.match(alias): + 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" diff --git a/tst/unit/a2a_agent/resolve_test.py b/tst/unit/a2a_agent/resolve_test.py index 71076471..22e77524 100644 --- a/tst/unit/a2a_agent/resolve_test.py +++ b/tst/unit/a2a_agent/resolve_test.py @@ -70,3 +70,18 @@ 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.resolve("helper") + + +def test_a_version_the_stores_agent_of_an_alias_id_lacks_is_not_found(aliases): + _put(SHIPPED) + _put("claude-code") + + assert A2AAgent.resolve("claude-code", 1).id == "claude-code" + with pytest.raises(NotFoundError): + A2AAgent.resolve("claude-code", 2) + + +def test_the_stores_agent_of_a_refused_alias_id_is_the_one_it_names(aliases): + _put("helper") + + assert A2AAgent.resolve("helper").id == "helper" diff --git a/tst/unit/bundle/aliases_test.py b/tst/unit/bundle/aliases_test.py index 32eeedfc..aeda2d7e 100644 --- a/tst/unit/bundle/aliases_test.py +++ b/tst/unit/bundle/aliases_test.py @@ -35,6 +35,7 @@ def test_a_bundle_without_an_aliases_file_gives_none(tmp_path): ('[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"), @@ -42,7 +43,7 @@ def test_a_bundle_without_an_aliases_file_gives_none(tmp_path): ('[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", "not-a-list", "empty", "two-agents", "twice", "other-table", +], 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) @@ -50,6 +51,16 @@ def test_an_aliases_file_with_a_problem_gives_no_aliases_and_says_why(tmp_path, 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"))], + ids=["folder", "broken-symlink"]) +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 == {} and problem.startswith("can't be read: ") + + 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') @@ -141,3 +152,14 @@ def test_a_misspelled_aliases_file_is_refused_rather_than_ignored(tmp_path): [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"), +], ids=["folder", "broken-symlink"]) +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 5b81202a..18111966 100644 --- a/tst/unit/bundle/installed_entities_test.py +++ b/tst/unit/bundle/installed_entities_test.py @@ -405,6 +405,16 @@ def test_an_alias_takes_no_pin(bundle_dir, tmp_path): "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", @@ -418,6 +428,18 @@ def test_an_alias_two_installed_bundles_give_is_refused_naming_both(bundle_dir, "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_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"}]) diff --git a/tst/unit/bundle/installed_test.py b/tst/unit/bundle/installed_test.py index e55922f7..053bbe65 100644 --- a/tst/unit/bundle/installed_test.py +++ b/tst/unit/bundle/installed_test.py @@ -256,6 +256,23 @@ def test_the_cli_lists_each_bundles_aliases_and_the_plugin_report_says_which_a_b "'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) From 60ed43b63f6f5b37c41f0ea193e9ee9b59f261b2 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Thu, 22 Oct 2026 00:30:00 -0700 Subject: [PATCH 3/6] fix(bundle): refuse an aliases.toml that isn't a regular file before reading it A named pipe there would block every command that reads the installed aliases. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/agent_env/plugins/_bundles.py | 2 ++ tst/unit/bundle/aliases_test.py | 11 +++++++---- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/agent_env/plugins/_bundles.py b/src/agent_env/plugins/_bundles.py index 333d03e1..4c2b198a 100644 --- a/src/agent_env/plugins/_bundles.py +++ b/src/agent_env/plugins/_bundles.py @@ -176,6 +176,8 @@ def read_aliases(path: Path) -> tuple[dict[str, tuple[str, ...]], list[str]]: 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: diff --git a/tst/unit/bundle/aliases_test.py b/tst/unit/bundle/aliases_test.py index aeda2d7e..f6857c53 100644 --- a/tst/unit/bundle/aliases_test.py +++ b/tst/unit/bundle/aliases_test.py @@ -1,5 +1,7 @@ """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 @@ -51,14 +53,14 @@ def test_an_aliases_file_with_a_problem_gives_no_aliases_and_says_why(tmp_path, 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"))], - ids=["folder", "broken-symlink"]) +@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 == {} and problem.startswith("can't be read: ") + 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): @@ -157,7 +159,8 @@ def test_a_misspelled_aliases_file_is_refused_rather_than_ignored(tmp_path): @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"), -], ids=["folder", "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) From e5ba802f61cfcb38ae792dcd8fdf829ada01bb8f Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Thu, 22 Oct 2026 08:26:23 -0700 Subject: [PATCH 4/6] test(bundle): the alias claude-code runs the shipped agent until the store has its own A bundle run and a step outside one both name the shipped Claude Code by its alias, and both name the store's agent once one is put under that id; the stored tasks hold the id each run resolved. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../cli/run_bundle_claude_code_test.py | 52 ++++++++++++++++++- 1 file changed, 50 insertions(+), 2 deletions(-) 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", [ From 592bf69dc5c45489b06a3842f2c603fb9d60459c Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Thu, 22 Oct 2026 08:39:30 -0700 Subject: [PATCH 5/6] feat(cli): a2a-agent list shows the agents a task can name, and get resolves an alias `agent-env a2a-agent list` lists each agent the installed bundles ship, with the aliases that name it (marking one the store's agent of that id shadows), then the store's agents newest first, 50 unless --limit says otherwise; --json for scripts. `a2a-agent get --id ` prints the agent a step naming it deploys. A name close to an alias that names no agent says which alias it may mean, at plan time and at run time. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 3 +- src/agent_env/a2a_agent/a2a_agent.py | 6 +- src/agent_env/agents/README.md | 3 +- src/agent_env/bundle/plan.py | 7 +- src/agent_env/cli/a2a_agent/__init__.py | 2 + src/agent_env/cli/a2a_agent/get.py | 10 +- src/agent_env/cli/a2a_agent/list_agents.py | 101 +++++++++++++++++ src/agent_env/plugins/_bundles.py | 13 +++ tst/unit/a2a_agent/resolve_test.py | 10 ++ tst/unit/bundle/aliases_test.py | 8 ++ tst/unit/bundle/installed_entities_test.py | 9 ++ tst/unit/cli/a2a_agent_list_test.py | 126 +++++++++++++++++++++ 12 files changed, 291 insertions(+), 7 deletions(-) create mode 100644 src/agent_env/cli/a2a_agent/list_agents.py create mode 100644 tst/unit/cli/a2a_agent_list_test.py diff --git a/AGENTS.md b/AGENTS.md index 24cd5863..d975a557 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -152,7 +152,8 @@ Out-of-tree code plugs in either through `config.toml`, as a `module:Class` poin 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 looks one up through `A2AAgent.resolve`, store first. agent-env's own bundles' aliases are - theirs, and an alias two other bundles give is refused, naming both. + 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` resolves an alias as a step does. - 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 569c68c0..c33faa37 100644 --- a/src/agent_env/a2a_agent/a2a_agent.py +++ b/src/agent_env/a2a_agent/a2a_agent.py @@ -414,7 +414,11 @@ def resolve(cls, id: str, version: Optional[int] = None) -> A2AAgent: except NotFoundError: aliases = installed_aliases() named, refusal = aliases.target(id), aliases.refusal(id) - if named is None and refusal is None or version is not None and cls._stored(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 cls._stored(id): raise if named is None: raise NotFoundError(refusal) from None diff --git a/src/agent_env/agents/README.md b/src/agent_env/agents/README.md index 2105dfbc..4c5a5c89 100644 --- a/src/agent_env/agents/README.md +++ b/src/agent_env/agents/README.md @@ -5,7 +5,8 @@ server: the prompt goes to `claude -p`, the task env's MCP servers to `--mcp-con 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 `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. +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/bundle/plan.py b/src/agent_env/bundle/plan.py index ff31e171..cbb3387a 100644 --- a/src/agent_env/bundle/plan.py +++ b/src/agent_env/bundle/plan.py @@ -198,6 +198,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]] = {} @@ -258,6 +259,7 @@ def _resolve_aliases(self) -> dict[str, AliasUse]: 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 @@ -604,7 +606,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}" 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..fff9b8e4 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) + agent = A2AAgent.resolve(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/plugins/_bundles.py b/src/agent_env/plugins/_bundles.py index 4c2b198a..27c0cfdd 100644 --- a/src/agent_env/plugins/_bundles.py +++ b/src/agent_env/plugins/_bundles.py @@ -13,6 +13,7 @@ from __future__ import annotations +import difflib import importlib.util import os import re @@ -170,6 +171,18 @@ 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 diff --git a/tst/unit/a2a_agent/resolve_test.py b/tst/unit/a2a_agent/resolve_test.py index 22e77524..acfdb0eb 100644 --- a/tst/unit/a2a_agent/resolve_test.py +++ b/tst/unit/a2a_agent/resolve_test.py @@ -85,3 +85,13 @@ def test_the_stores_agent_of_a_refused_alias_id_is_the_one_it_names(aliases): _put("helper") assert A2AAgent.resolve("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.resolve("claude_code") + with pytest.raises(NotFoundError) as pinned: + A2AAgent.resolve("claude_code", 2) + + assert "did you mean" not in str(pinned.value) diff --git a/tst/unit/bundle/aliases_test.py b/tst/unit/bundle/aliases_test.py index f6857c53..8de828da 100644 --- a/tst/unit/bundle/aliases_test.py +++ b/tst/unit/bundle/aliases_test.py @@ -86,6 +86,14 @@ def test_agent_envs_own_aliases_are_refused_to_any_other_bundle(tmp_path): "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') diff --git a/tst/unit/bundle/installed_entities_test.py b/tst/unit/bundle/installed_entities_test.py index 18111966..a628a524 100644 --- a/tst/unit/bundle/installed_entities_test.py +++ b/tst/unit/bundle/installed_entities_test.py @@ -440,6 +440,15 @@ def test_the_stores_agent_of_a_refused_alias_id_is_the_one_it_names(bundle_dir, 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"}]) 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 From 2a37e04d93f474e8e0098370aaac76963f849874 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Thu, 22 Oct 2026 09:27:11 -0700 Subject: [PATCH 6/6] refactor(a2a-agent): A2AAgent.get resolves an alias, so every lookup does One get replaces get and resolve: the store's agent of the id, else the agent an installed alias names, so the validator steps and `a2a-agent deploy`/`validate` take an alias as the deploy, install and judge steps do. The planner reads the store by exact id itself (STORE_GETTERS), as it weighs aliases against the store on its own. A pinned alias is now NotFoundError like any other miss, and an id that can't be an alias skips the alias table. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 11 ++--- src/agent_env/a2a_agent/a2a_agent.py | 45 +++++++++---------- src/agent_env/bundle/plan.py | 9 +++- src/agent_env/cli/a2a_agent/get.py | 2 +- src/agent_env/cli/a2a_agent/validate.py | 4 +- .../task_step/task_steps/deploy_agent.py | 2 +- .../task_step/task_steps/install_agent.py | 4 +- .../task_steps/verifiers/rubrics_verifier.py | 2 +- .../{resolve_test.py => alias_get_test.py} | 34 ++++++++------ .../task_step/unwritten_local_agent_test.py | 25 +++++++---- 10 files changed, 81 insertions(+), 57 deletions(-) rename tst/unit/a2a_agent/{resolve_test.py => alias_get_test.py} (73%) diff --git a/AGENTS.md b/AGENTS.md index d975a557..8daa9e3b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -147,13 +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 +- 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 looks one up through `A2AAgent.resolve`, store first. 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` resolves an alias as a step does. + 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 c33faa37..d9714f50 100644 --- a/src/agent_env/a2a_agent/a2a_agent.py +++ b/src/agent_env/a2a_agent/a2a_agent.py @@ -43,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 @@ -76,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 @@ -400,45 +409,35 @@ 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) - - @classmethod - def resolve(cls, id: str, version: Optional[int] = None) -> A2AAgent: - """The agent ``id`` names: the store's of that id, else the one an installed bundle gives the alias ``id``, - which the store holds once a run of a bundle has used it. Raises NotFoundError, and ValueError for an alias with - a version: it means the agent its bundle ships. A pinned version the store's agent of that id lacks is just - missing.""" + store = get_a2a_agent_store() try: - return cls.get(id, version) + 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 cls._stored(id): + 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 ValueError(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") + 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 cls.get(named.agent_id) + 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 _stored(cls, id: str) -> bool: - try: - cls.get(id) - except NotFoundError: - return False - return True - @classmethod def put(cls, **kwargs) -> A2AAgent: from .store import get_a2a_agent_store diff --git a/src/agent_env/bundle/plan.py b/src/agent_env/bundle/plan.py index cbb3387a..63c087a9 100644 --- a/src/agent_env/bundle/plan.py +++ b/src/agent_env/bundle/plan.py @@ -25,6 +25,7 @@ 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 @@ -69,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. diff --git a/src/agent_env/cli/a2a_agent/get.py b/src/agent_env/cli/a2a_agent/get.py index fff9b8e4..b281af14 100644 --- a/src/agent_env/cli/a2a_agent/get.py +++ b/src/agent_env/cli/a2a_agent/get.py @@ -13,7 +13,7 @@ def get(agent_id: str, agent_version: int | None): """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.resolve(agent_id, agent_version) + 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/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/task_step/task_steps/deploy_agent.py b/src/agent_env/task_step/task_steps/deploy_agent.py index ae93032a..f2b3728b 100644 --- a/src/agent_env/task_step/task_steps/deploy_agent.py +++ b/src/agent_env/task_step/task_steps/deploy_agent.py @@ -357,7 +357,7 @@ async def _execute(self, context: TaskStepContext, deployed_agents: list) -> Tas user_overrides = context.metadata.get("user_overrides", {}) a2a_agent_id, a2a_agent_version = self._resolve_agent_ref(context) try: - agent = A2AAgent.resolve(a2a_agent_id, a2a_agent_version) + agent = A2AAgent.get(a2a_agent_id, a2a_agent_version) except NotFoundError: if not is_local_id(a2a_agent_id): raise diff --git a/src/agent_env/task_step/task_steps/install_agent.py b/src/agent_env/task_step/task_steps/install_agent.py index 0aa4259a..40df1eb2 100644 --- a/src/agent_env/task_step/task_steps/install_agent.py +++ b/src/agent_env/task_step/task_steps/install_agent.py @@ -171,7 +171,7 @@ def preflight(self) -> list[str]: run, whose ``a2a_agent_id`` override can name another, so a task saved naming a placeholder still saves. A step naming no agent installs the configured default.""" try: - agent = A2AAgent.resolve(self.a2a_agent_id or get_config().get_default_a2a_agent_id(), self.a2a_agent_version) + agent = A2AAgent.get(self.a2a_agent_id or get_config().get_default_a2a_agent_id(), self.a2a_agent_version) except (NotFoundError, ValueError, KeyError, TypeError): return [] problem = _install_problem(agent) @@ -222,7 +222,7 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: # caller swap which agent gets installed without rewriting the task definition. user_overrides = context.metadata.get("user_overrides", {}) a2a_agent_id = user_overrides.get("a2a_agent_id") or self.a2a_agent_id or get_config().get_default_a2a_agent_id() - agent = A2AAgent.resolve(a2a_agent_id, self.a2a_agent_version) + agent = A2AAgent.get(a2a_agent_id, self.a2a_agent_version) if problem := _install_problem(agent): raise RuntimeError(problem) diff --git a/src/agent_env/task_step/task_steps/verifiers/rubrics_verifier.py b/src/agent_env/task_step/task_steps/verifiers/rubrics_verifier.py index a1e3d786..e908bb61 100644 --- a/src/agent_env/task_step/task_steps/verifiers/rubrics_verifier.py +++ b/src/agent_env/task_step/task_steps/verifiers/rubrics_verifier.py @@ -766,7 +766,7 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: env_vars = {"LITELLM_API_KEY": api_key, "LITELLM_BASE_URL": litellm_base_url} resolved_judge_id = self.judge_a2a_agent_id or config.get_default_a2a_agent_id() try: - a2a_agent = A2AAgent.resolve(resolved_judge_id) + a2a_agent = A2AAgent.get(resolved_judge_id) except NotFoundError: if not is_local_id(resolved_judge_id): raise diff --git a/tst/unit/a2a_agent/resolve_test.py b/tst/unit/a2a_agent/alias_get_test.py similarity index 73% rename from tst/unit/a2a_agent/resolve_test.py rename to tst/unit/a2a_agent/alias_get_test.py index acfdb0eb..271cbcac 100644 --- a/tst/unit/a2a_agent/resolve_test.py +++ b/tst/unit/a2a_agent/alias_get_test.py @@ -1,4 +1,4 @@ -"""An agent looked up as a step deploys it: the store's of that id, else the agent an installed bundle's alias names.""" +"""A2AAgent.get: the store's agent of an id, else the agent an installed bundle's alias of that id names.""" import pytest @@ -35,19 +35,19 @@ def _put(agent_id): def test_an_alias_names_the_agent_its_bundle_gives_it(aliases): _put(SHIPPED) - assert A2AAgent.resolve("claude-code").id == 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.resolve("claude-code").id == "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.resolve("nobody") + A2AAgent.get("nobody") assert "nobody" in str(e.value) @@ -55,43 +55,51 @@ def test_an_id_that_is_no_alias_is_not_found_as_before(aliases): def test_an_alias_takes_no_version(aliases): _put(SHIPPED) - with pytest.raises(ValueError, match=f"'claude-code' is an alias of {SHIPPED}, which comes with agentenv-framework's " + 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.resolve("claude-code", 1) + 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.resolve("claude-code") + 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.resolve("helper") + 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.resolve("claude-code", 1).id == "claude-code" + assert A2AAgent.get("claude-code", 1).id == "claude-code" with pytest.raises(NotFoundError): - A2AAgent.resolve("claude-code", 2) + 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.resolve("helper").id == "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.resolve("claude_code") + A2AAgent.get("claude_code") with pytest.raises(NotFoundError) as pinned: - A2AAgent.resolve("claude_code", 2) + 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/task_step/unwritten_local_agent_test.py b/tst/unit/task_step/unwritten_local_agent_test.py index 1c43e3d3..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,13 @@ """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. A step naming an agent by an installed bundle's alias deploys the -agent the alias names.""" +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 @@ -18,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 @@ -58,12 +59,12 @@ class _Looked(Exception): @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(cls, id, version=None): + def get(self, id, version=None): if id == AGENT: raise _Looked(id) raise NotFoundError(f"A2AAgent {id} not found") - monkeypatch.setattr(A2AAgent, "get", classmethod(get)) + monkeypatch.setattr(A2AAgentStore, "get", get) @pytest.mark.asyncio @@ -89,3 +90,11 @@ async def test_a_judge_named_by_the_shipped_alias_is_the_agent_it_names(aliased_ 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]))