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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ and deserialized through a registry.
| `task/`, `task_step/` | A `Task` holds its `TaskStep`s inline; `Task.run()` executes them as a DAG. `depends_on` (None means all prior steps) gates a step, independent steps run concurrently, `fail_task_on_error` makes a failure fatal or tolerated, `retry_config` rolls a failed span back through the step journal and re-dispatches it. Built-in steps live in `task_step/task_steps/` (`deploy_env`, `deploy_agent`, `prompt_agent`, the verifiers under `verifiers/`, and more). |
| `store/` | Four store ABCs with local and cloud implementations: `DocumentStore` (SQLite, MongoDB), `ObjectStore` (filesystem, S3, Cloud Storage), `ImageStore` (local OCI registry, ECR), `SecretStore` (env vars or file, AWS Secrets Manager, Google Cloud Secret Manager). `VersionedEntityStore` implements the shared versioned get/put logic, `QueryBuilder` is the immutable chained query API, `store/base.py` holds the error types. A new backend must pass the conformance kits in `tst/store/`. |
| `config/` | The `Config` singleton (`get_config`, `configure`, `reset_config`) in `config/runtime.py`, file discovery in `config/loader.py`, and `load_impl`, which resolves `module:Class` pointers. `agent_env.store` re-exports the config names for compatibility. |
| `providers/` | `providers/sandbox_providers/` holds the sandbox providers `local`, `modal`, `modal_vm`, `e2b`, `sail_vm` (the `sail` extra); `[sandbox] default` and `agent_default` accept a comma-separated fallback chain. A sandbox provider declares what it runs (`ON_THIS_MACHINE`, `CREATES_VMS`, `SANDBOX_ACCEPTS`, `CONTAINER_ACCEPTS`, `PRIVATE_NETWORK`), and code outside `providers/sandbox_providers/` reads those, never which provider it has. `providers/env_providers/` holds the environment providers: `EnvironmentProvider` (an env's containers and state store) and `EnvironmentGatewayProvider`, which renders a docker-compose for the gateway and its MCP servers inside the sandbox; `providers/env_state/` holds env-state providers (`local_postgres` built in). |
| `providers/` | `providers/sandbox_providers/` holds the sandbox providers `local`, `modal`, `modal_vm`, `e2b`, `sail_vm` (the `sail` extra); `[sandbox] default` and `agent_default` accept a comma-separated fallback chain, whose deploys skip a provider that can't run them. A sandbox provider declares what it runs (`ON_THIS_MACHINE`, `SANDBOX_ACCEPTS`, `CONTAINER_ACCEPTS`, `PRIVATE_NETWORK`, and `SUPPORTED_SANDBOX_MODES`, derived from whether it implements `create_sandbox` and `create_vm`), each sandbox carries the `platform` of the images it runs (linux/amd64 unless its provider sets another when it creates it; None, any, unchecked, for a local sandbox), and code outside `providers/sandbox_providers/` reads those, never which provider it has. `providers/env_providers/` holds the environment providers: `EnvironmentProvider` (an env's containers and state store) and `EnvironmentGatewayProvider`, which renders a docker-compose for the gateway and its MCP servers inside the sandbox; `providers/env_state/` holds env-state providers (`local_postgres` built in). |
| `a2a_agent/` | The `A2AAgent` entity (`a2a_agent`), its stores and the validator steps. The protocol package provides the agent-side framework. |
| `runner/` | The `[runner]` seam: `Runner.submit()` returns `(run_id, instance_id)`; `LocalRunner` is built in. |
| `explorer/` | Optional local web UI: `agent-env up`, needs the `explorer` extra, binds loopback `:8234`. |
Expand Down
36 changes: 25 additions & 11 deletions src/agent_env/a2a_agent/a2a_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from __future__ import annotations

import asyncio
import functools
import json
import logging
import posixpath
Expand All @@ -26,8 +27,10 @@
skill_add_call,
)
from agent_env.a2a_agent.staging import transfer_store
from agent_env.providers.sandbox_providers.chained_sandbox_provider import skip_refusing
from agent_env.providers.sandbox_providers.local_sandbox import LOCAL_TRUST_ENV, local_grant_trust, start_trusting
from agent_env.providers.sandbox_providers.sandbox_provider import (
SANDBOX_MODE_VM,
Accepts,
SandboxProvider,
all_sandbox_container_env,
Expand Down Expand Up @@ -120,8 +123,11 @@ class A2AAgent:
SNAPSHOT_METHOD_APPLY_CHANGELOG: ClassVar[str] = "apply-changelog"

@staticmethod
def accepts(sandbox_provider: SandboxProvider) -> Accepts:
"""The images an agent deploys from on ``sandbox_provider``: those its sandboxes run (``create_sandbox``)."""
def accepts(sandbox_provider: SandboxProvider, *, sandbox_mode: str | None = None) -> Accepts:
"""The images an agent deploys from on ``sandbox_provider``: those its own sandboxes run (``create_sandbox``),
or, placed on a VM sandbox a task deployed there (``sandbox_mode`` "vm"), any that VM loads."""
if sandbox_mode == SANDBOX_MODE_VM:
return Accepts.LOADABLE
return sandbox_provider.SANDBOX_ACCEPTS

@staticmethod
Expand Down Expand Up @@ -412,7 +418,6 @@ async def deploy(
attribution: Optional[Attribution] = None,
) -> DeployedA2AAgent:
from agent_env.providers.sandbox_providers.sandbox_provider import (
SANDBOX_MODE_VM,
build_sandbox_provider,
get_agent_sandbox_provider,
)
Expand All @@ -434,15 +439,16 @@ async def deploy(
resolved_env["LITELLM_BASE_URL"] = (provider or sandbox).url_from_sandbox(resolved_env["LITELLM_BASE_URL"])

merged_env = await asyncio.to_thread(self._build_merged_env, resolved_env, a2a_port)
image_name = self.docker_image_artifact.image_name
if problem := self.docker_image_artifact.by_name_problem():
if provider is None and sandbox.mode != SANDBOX_MODE_VM:
image = self.docker_image_artifact
image_name = image.image_name
if provider is None:
# A container sandbox pulls nothing, so only an image that has to be built can't be what it runs.
if sandbox.mode != SANDBOX_MODE_VM and image.context_only:
raise ValueError(f"Can't deploy agent {self.id!r} on the container sandbox {sandbox.sandbox_id!r}, which "
f"runs its own image, never the agent's: {problem}")
links = provider.links if provider else ()
if by_name := [link for link in links if self.accepts(link).problem(self.docker_image_artifact)]:
raise ValueError(f"Can't deploy agent {self.id!r} with {type(by_name[0]).__name__}, which runs an "
f"agent's image by name: {problem}")
f"runs its own image, never the agent's: {image.id!r} v{image.version} is only a build "
"context, which has to be built, not pulled")
else:
provider = skip_refusing(provider, functools.partial(self._refusal, image), what=f"agent {self.id!r}")

try:
if provider is not None:
Expand Down Expand Up @@ -499,6 +505,14 @@ async def deploy(
await self.close()
raise

def _refusal(self, image: DockerImageArtifact, link: SandboxProvider) -> str | None:
"""Why ``link`` can't run ``image`` as this agent's own sandbox, as a clause on its name, or None when it can."""
accepts = self.accepts(link)
if problem := accepts.problem(image, on_this_machine=link.ON_THIS_MACHINE):
how = "whose VMs can't run it" if accepts is Accepts.LOADABLE else "which runs an agent's image by name"
return f"{how}: {problem}"
return None

def _build_merged_env(self, resolved_env: dict[str, str], a2a_port: int) -> dict[str, str]:
merged_env = dict(self.default_env_vars)
merged_env.update(resolved_env)
Expand Down
47 changes: 33 additions & 14 deletions src/agent_env/artifact/artifacts/docker_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
from agent_env.store.image_store.oci_registry_credentials import is_loopback_host, names_registry, registry_host_from_ref
from agent_env.store.image_store.registry_api import pin_digest
from agent_env.utils.build_context import BuildContext
from agent_env.utils.docker_build import DEFAULT_BUILD_PLATFORM
from agent_env.utils.docker_build import DEFAULT_BUILD_PLATFORM, normalize_platform

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -125,7 +125,7 @@ def put(
image_ref = image_store.image_ref(repository, f"v{version}")
image_store.ensure_repository(repository)
_push_local_image(image_name, image_ref, image_store)
platform = platform or _image_platform(image_ref)
platform = normalize_platform(platform) if platform else _image_platform(image_ref)

with tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False) as tmp:
tmp_path = Path(tmp.name)
Expand Down Expand Up @@ -198,7 +198,7 @@ def put_tar(
tar_gz_object_url=tar_gz_object_url,
build_context_object_url=build_context_object_url,
dockerfile_path=dockerfile_path,
platform=platform,
platform=normalize_platform(platform) if platform else None,
source_digest=source_digest,
)
return store.put_document(instance)
Expand Down Expand Up @@ -238,6 +238,7 @@ def put_context(cls, id: str, *, description: str, context_path: str, dockerfile
from agent_env.artifact.store import get_artifact_store
from agent_env.config import get_config

platform = normalize_platform(platform)
root = Path(context_path)
dockerfile = _resolve_dockerfile(root, dockerfile_path)
if not dockerfile.is_file():
Expand Down Expand Up @@ -274,21 +275,33 @@ def context_only(self) -> bool:
tag the image's build gives it."""
return bool(self.build_context_object_url) and not self.tar_gz_object_url and not names_registry(self.image_name)

def load_problem(self) -> str | None:
"""Why no VM sandbox can get this image, or None when one can: a tar.gz is loaded, an image with none is pulled
when its name names a registry, and built from its build context when it has only that."""
if self.tar_gz_object_url or names_registry(self.image_name) or self.context_only:
return None
def load_problem(self, *, on_this_machine: bool, platform: str | None = None) -> str | None:
"""Why a VM sandbox can't get this image, or None when it can: a tar.gz is loaded, an image with none is pulled
when its name names a registry, one that a VM ``on_this_machine`` or not reaches, and built from its build
context when it has only that; and a VM of ``platform`` runs only an image built for it."""
if self.tar_gz_object_url or self.context_only:
return self.platform_problem(platform)
if names_registry(self.image_name):
return pull_problem(self.image_name, on_this_machine=on_this_machine) or self.platform_problem(platform)
return (f"{self.id!r} v{self.version} has no tar.gz, and its image name {self.image_name!r} doesn't name a "
"registry to pull it from")

def by_name_problem(self) -> str | None:
def by_name_problem(self, *, on_this_machine: bool) -> str | None:
"""Why a sandbox that runs an image by pulling its name can't run this one, or None when it can: a context-only
image's name is a tag only its build gives it, a VM sandbox's or Modal's, and pulled, it would be looked up on
Docker Hub."""
Docker Hub; and a sandbox not ``on_this_machine`` can't pull from a registry on this machine."""
if self.context_only:
return f"{self.id!r} v{self.version} is only a build context, which has to be built, not pulled"
return None
return pull_problem(self.image_name, on_this_machine=on_this_machine)

def platform_problem(self, platform: str | None) -> str | None:
"""Why a sandbox that runs images of ``platform`` can't run this one, or None when it can: an image runs on the
platform it records, the one it's built for, however either is spelled (``normalize_platform``). One that
records none, or a sandbox that runs any (None), isn't checked."""
if platform is None or self.platform is None or normalize_platform(self.platform) == normalize_platform(platform):
return None
return (f"{self.id!r} v{self.version} is a {self.platform} image, and the sandbox runs {platform}; rebuild it for "
f"{platform}, which a Linux host of another platform does only with QEMU emulation set up")

@classmethod
async def put_from_github(
Expand Down Expand Up @@ -466,6 +479,14 @@ def _parse_github_url(url: str) -> DockerImageArtifact._GitHubURLParts:
return DockerImageArtifact._GitHubURLParts(owner=owner, repo=repo, ref=ref, path=path)


def pull_problem(image_name: str, *, on_this_machine: bool) -> str | None:
"""Why a sandbox pulling ``image_name`` can't, or None when it can: one that isn't ``on_this_machine`` can't reach a
registry on this machine."""
if on_this_machine or not is_loopback_host(registry_host_from_ref(image_name)):
return None
return f"{image_name} is in a registry on this machine, which a sandbox elsewhere can't pull from"


def refuse_local_github_build(entity_id: str) -> None:
"""A GitHub build publishes to the configured registry, so it takes a registry id; an ``@local``
image is built from its own Dockerfile."""
Expand Down Expand Up @@ -538,9 +559,7 @@ def _image_platform(image_ref: str) -> str | None:
capture_output=True, text=True)
if result.returncode != 0 or result.stdout.count("/") != 2:
return None
os_name, architecture, variant = result.stdout.strip().split("/")
# arm64 has one variant, so it goes unnamed as in `--platform linux/arm64`; 32-bit arm's v6 and v7 differ.
return f"{os_name}/{architecture}/{variant}" if architecture == "arm" and variant else f"{os_name}/{architecture}"
return normalize_platform(result.stdout)


def _resolve_dockerfile(context_dir: Path, dockerfile_path: str | None) -> Path | None:
Expand Down
Loading
Loading