Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,16 @@ Out-of-tree code plugs in either through `config.toml`, as a `module:Class` poin
the solver for one run (each step deploying or installing an agent a `prompt_agent` step prompts, not its user
simulator: `plan.solver_steps`), pulled in and checked as a task's reference to it would be, through per-step
`step_params` overrides, so the tasks stay as written.
- `aliases.toml` gives envs aliases the same way (`[envs] <folder> = ["<alias>", ...]`; the `mail-mcp` example names
its env `mail`), resolved by the planner (`Plan.env_aliases`) and by `Env.get`, only in a task's steps: an env
definition (a MultiEnv's servers) naming one is refused. The env `Env.get` gives for an alias answers to the alias's
id, so a deployment records, and steps look it up by, the id the task names; its metadata writes go to the env the
alias names (`Env.stored_id`). An alias names its env only where namespace routing is on. An env store refuses to
create an env under an installed env alias's id (`EnvStore.put_document`), so a deployment recorded under an alias
always reloads the alias's env; an env stored under such an id before keeps taking versions.
`A2AAgent.validate()` deploys `mail` (`A2AAgent.VALIDATION_ENV_ID`) with namespace routing on, first writing the
example (its writes and infra, no run) when the store has no env of that id: on a remote sandbox its image is
context-only and built in the VM, so a service like the hub's worker needs no Docker.
- Environment providers have no table: an installed package registers one as an `agent_env.env_providers`
entry point named its `type`, which records carry as `env_provider_type`.
- `[plugins.<distribution name>]` is reserved for an installed plugin's own settings, read with
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ agent-env = "agent_env.cli:cli"
agents = "agent_env"
hello = "agent_env.examples"
echo-mcp = "agent_env.examples"
mail-mcp = "agent_env.examples"

[project.urls]
Homepage = "https://www.agentenvframework.com"
Expand Down
14 changes: 8 additions & 6 deletions src/agent_env/a2a_agent/a2a_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -430,13 +430,13 @@ def get(cls, id: str, version: Optional[int] = None) -> A2AAgent:
if named is None:
raise NotFoundError(refusal) from None
if version is not None:
raise NotFoundError(f"{id!r} is an alias of {named.agent_id}, which comes with {named.declared_by}, so "
"it has no fixed version; drop the pin") from None
raise NotFoundError(f"{id!r} is an alias of {named.target_id}, which comes with "
f"{named.declared_by}, so it has no fixed version; drop the pin") from None
try:
return store.get(named.agent_id)
return store.get(named.target_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
raise NotFoundError(f"{id!r} is an alias of {named.target_id}, which comes with {named.declared_by} and "
"isn't in the store yet: an `agent-env run` of a bundle that uses it writes it") from None

@classmethod
def put(cls, **kwargs) -> A2AAgent:
Expand All @@ -450,7 +450,9 @@ def query(cls) -> A2AAgentQuery:
from .store import A2AAgentQuery, get_a2a_agent_store
return A2AAgentQuery(get_a2a_agent_store())

VALIDATION_ENV_ID: ClassVar[str] = "multi-caf64d"
# The env validate gives the agent it checks: the alias of the mail-mcp example's env, which validate writes when
# the store has no env of that id.
VALIDATION_ENV_ID: ClassVar[str] = "mail"

def update_metadata(self, new_metadata: dict[str, Any]) -> None:
"""Replace this agent's metadata in the database using compare-and-swap."""
Expand Down
40 changes: 36 additions & 4 deletions src/agent_env/a2a_agent/validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
import logging
import uuid
from dataclasses import dataclass
from typing import Callable, Optional, TYPE_CHECKING, Union
from typing import Callable, Optional, Union

from agentenv_protocol.a2a_agent import ObjectChangelogApplyResponse

Expand All @@ -29,7 +29,13 @@
invoke_transfer,
)
from agent_env.a2a_agent.staging import transfer_store
from agent_env.a2a_agent.a2a_agent import A2AAgent
from agent_env.bundle.run import _write_bundle
from agent_env.config import get_config
from agent_env.env.store import get_env_store
from agent_env.plugins._bundles import ENV_ALIASES, installed_aliases
from agent_env.store.base import NotFoundError
from agent_env.store.routing import namespace_routing
from agent_env.store.ids import derive_id, key_segment
from agent_env.task_step.task_steps.a2a_agent_validator.verify_a2a_modalities import (
AUDIO_M4A_EXPECTED,
Expand All @@ -56,8 +62,6 @@
)
from agent_env.task_step.task_steps.sandbox_utils.sandbox_utils import find_agent_container

if TYPE_CHECKING:
from agent_env.a2a_agent.a2a_agent import A2AAgent

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -123,6 +127,22 @@ class ModalityProbe:
]


def write_validation_env(on_progress: Callable[[str], None]) -> None:
"""Write the env the validator deploys, ``A2AAgent.VALIDATION_ENV_ID``, when the store has none of that id: the env
an installed bundle gives that alias, with the infra it deploys on."""
env_id = A2AAgent.VALIDATION_ENV_ID
try:
get_env_store().get(env_id)
return
except NotFoundError:
pass
named = installed_aliases(table=ENV_ALIASES).target(env_id)
if named is None: # deploying it says why it can't be found
return
on_progress(f"{env_id} → {named.target_id} (the alias {named.declared_by} gives it): writing it")
_write_bundle(named.bundle.root, id_root=named.bundle.id_root, on_progress=on_progress)


class A2AAgentValidator:
"""Static namespace for running the full A2A agent validation flow."""

Expand All @@ -135,8 +155,20 @@ async def validate(
"""Run the A2A agent validator task: deploy env + agent, verify card,
modality probes, MCP tools, skills, snapshot extension, etc.

Returns the merged `verifications` dict from the run's task context.
Namespace routing is on throughout, so the env it deploys, ``A2AAgent.VALIDATION_ENV_ID``, can be the
``@local`` env an installed bundle's alias names, written first (``write_validation_env``) in a thread so a
service's event loop isn't held up. Returns the merged `verifications` dict from the run's task context.
"""
with namespace_routing():
await asyncio.to_thread(write_validation_env, on_progress or (lambda line: None))
return await A2AAgentValidator._run(agent, on_progress, litellm_api_key)

@staticmethod
async def _run(
agent: "A2AAgent",
on_progress: Optional[Callable[[str], None]] = None,
litellm_api_key: Optional[str] = None,
) -> dict:
from agent_env.task import Task
from agent_env.task_step import (
AddSkillsTaskStep,
Expand Down
7 changes: 4 additions & 3 deletions src/agent_env/agents/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,9 +228,10 @@ agent-env a2a-agent validate --id my-agent # agent-env's built
agent-env a2a-agent get --id my-agent # the validation results
```

`validate` runs a fixed set of checks against the reference env `multi-caf64d`, which must exist in the document
store; the checks are in `src/agent_env/a2a_agent/validator.py` and
`src/agent_env/task_step/task_steps/a2a_agent_validator/`, one step each. It doesn't cover everything an agent can
`validate` runs a fixed set of checks against the reference env `mail`, an inbox with a `search_emails` tool: the alias
of the `mail-mcp` example's env (`src/agent_env/examples/mail-mcp/`), which `validate` writes before it runs, with its
image context-only on a remote sandbox, when the store has no env of that id. The checks are in
`src/agent_env/a2a_agent/validator.py` and `src/agent_env/task_step/task_steps/a2a_agent_validator/`, one step each. It doesn't cover everything an agent can
advertise: `triggers/v1`, `attribution-probe/v1`, host-mode `install/v1` and custom extensions need tests of your
own.

Expand Down
47 changes: 25 additions & 22 deletions src/agent_env/bundle/parse.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@
from pathlib import Path
from typing import Any, NoReturn

from agent_env.bundle._fs import Unreadable, fold, home, on_disk, read_regular, show
from agent_env.bundle._fs import Unreadable, fold, home, on_disk, read_regular, show, with_article
from agent_env.entity.refs import EntityKind
from agent_env.plugins._bundles import ALIASES_FILE, read_aliases
from agent_env.plugins._bundles import AGENT_ALIASES, ALIASES_FILE, ENV_ALIASES, 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, os_reason

Expand Down Expand Up @@ -84,9 +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)
# The aliases its aliases.toml gives its agents and envs, by table and then folder name, which a reference names
# one by once the bundle is installed.
aliases: Mapping[str, Mapping[str, tuple[str, ...]]] = field(default_factory=dict)


class BundleError(ValueError):
Expand Down Expand Up @@ -158,28 +158,31 @@ def parse(self) -> Bundle:
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."""
def _aliases(self, entries: list[BundleEntry]) -> dict[str, dict[str, tuple[str, ...]]]:
"""The aliases the bundle's aliases.toml gives its agents and envs. Each names one the bundle has, of its
table's kind, by its folder name, so it keeps the id its folder gives it, and none is another one's 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}")
for table, kind, noun in ((AGENT_ALIASES, BundleKind.AGENT, "agent"), (ENV_ALIASES, BundleKind.ENV, "env")):
own = {entry.name: entry for entry in entries if entry.kind is kind}
taken = {key: entry for entry in own.values() for key in (entry.name, entry.id)}
for name, aliases in declared.get(table, {}).items():
entry = own.get(name)
if entry is None:
self._problem(self.aliases_file, f"{table}.{name}: this bundle has no {noun} {name!r}")
continue
if entry.id != f"{self.id_root}/{name}":
self._problem(self.aliases_file, f"{table}.{name}: the {noun} declares the id {entry.id!r}, and an "
f"alias names {with_article(noun)} by its folder, so it must keep the id "
f"{self.id_root}/{name}")
for alias in aliases:
if (other := taken.get(alias)) is not None and other is not entry:
what = "name" if alias == other.name else "id"
self._problem(self.aliases_file,
f"{table}.{name}: {alias!r} is the {noun} {other.name!r}'s {what}")
return declared

def _derive_id_root(self) -> str:
Expand Down
Loading
Loading