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
5 changes: 5 additions & 0 deletions .agentenv/config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ agent_default = "local" # agents too
[agents]
default_a2a_agent_id = "a2a-default" # what a deploy_agent step that names no agent deploys

# [model]
# base_url = "http://localhost:4000" # a model proxy on this machine; local containers reach it as host.docker.internal
# # on Linux, bind it to the Docker bridge address and use that: http://172.17.0.1:4000
# api_key = "env:MY_PROXY_KEY"

[runner]
impl = "agent_env.runner.local_runner:LocalRunner"

Expand Down
7 changes: 6 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,12 @@ default stores are local.
sandboxes for envs and agents. MongoDB, S3, Cloud Storage, ECR, AWS Secrets Manager, Google Cloud
Secret Manager, Modal and E2B exist as implementations and are selected by config.
- The model endpoint is unset until `[model] base_url` / `api_key` (or `LITELLM_BASE_URL` /
`LITELLM_API_KEY`) is configured.
`LITELLM_API_KEY`) is configured. A loopback URL handed into a `local` container (the model
endpoint, an agent, peer or gateway on this machine) becomes `host.docker.internal`. On Linux that
name is the Docker bridge address (`docker0`, usually `172.17.0.1`), not loopback, so a service on
this machine that local containers call, a model proxy say, has to listen there: bind it to the
bridge address and use that address in its URL. Binding `0.0.0.0` also works, but exposes the
service to the network.
- `[agents] default_a2a_agent_id`: the agent a `deploy_agent` step without an id deploys; built-in
`a2a-default`, overridable by `configure(default_a2a_agent_id=...)`.
- Secrets never live in the file: use `secret:KEY` or `env:NAME` references, resolved through
Expand Down
12 changes: 7 additions & 5 deletions src/agent_env/a2a_agent/a2a_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -413,18 +413,22 @@ async def deploy(
disk_size_gb = float(self.metadata.get("min_disk_size_gb") or 10)

config = get_config()
provider = None if sandbox is not None else (
build_sandbox_provider(sandbox_type) if sandbox_type else get_agent_sandbox_provider()
)
resolved_env = dict(env_vars) if env_vars else {}
if "LITELLM_API_KEY" not in resolved_env and "LITELLM_API_KEY" not in self.default_env_vars:
resolved_env["LITELLM_API_KEY"] = config.get_litellm_api_key()
if "LITELLM_BASE_URL" not in resolved_env and "LITELLM_BASE_URL" not in self.default_env_vars:
resolved_env["LITELLM_BASE_URL"] = config.get_litellm_base_url()
if "LITELLM_BASE_URL" in resolved_env and isinstance(provider or sandbox, (LocalSandboxProvider, LocalSandbox)):
resolved_env["LITELLM_BASE_URL"] = LocalSandboxProvider.get_external_url(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

try:
if sandbox is None:
provider = build_sandbox_provider(sandbox_type) if sandbox_type else get_agent_sandbox_provider()
if provider is not None:
logger.info(f"Provisioning sandbox for A2A agent '{self.id}' via {type(provider).__name__}...")
self._sandbox = await provider.create_sandbox(
image_name=image_name, port=a2a_port, env=merged_env,
Expand Down Expand Up @@ -504,9 +508,7 @@ async def _run_container(self, image_name: str, a2a_port: int, merged_env: dict[
trust_dir = await asyncio.to_thread(local_grant_trust) if isinstance(self._sandbox, LocalSandbox) else None
agent_env = dict(merged_env) if trust_dir is None else {**LOCAL_TRUST_ENV, **merged_env}
setup_script = ""
network_flag = "" if trust_dir is None or not LocalSandboxProvider.EXTRA_CONTAINER_RUN_ARGS else (
f"{LocalSandboxProvider.EXTRA_CONTAINER_RUN_ARGS} \\\n "
)
network_flag = "".join(f"--add-host {entry} \\\n " for entry in self._sandbox.extra_hosts)
if enable_docker:
logger.info("enable_docker: starting rootless Docker-in-Docker sidecar for the agent (no host socket)")
setup_script = self._dind_setup_script()
Expand Down
1 change: 1 addition & 0 deletions src/agent_env/env/envs/multi_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -610,6 +610,7 @@ async def _load_from_snapshot(self, snapshot) -> None:
state_provider=gw._state_provider, # local-only, set above
state_instance=gw._state_instance,
host_ips=self._sandbox.host_ips,
extra_hosts=self._sandbox.extra_hosts,
mcp_server_name=self._mcp_server_name or self.name,
)
compose_content = compose_content.replace(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ def create_docker_compose(
state_instance: "EnvStateInstance",
host_port: Optional[Callable[[int], int]] = None,
host_ips: tuple[str, ...] = (),
extra_hosts: tuple[str, ...] = (),
mcp_server_name: str | None = None,
) -> str:
"""Generate docker-compose.yml content for a gateway deployment onto a VM/laptop/arbitrary machine.
Expand Down Expand Up @@ -420,10 +421,13 @@ def create_docker_compose(
" ports:",
*(f' - "{spec}"' for spec in port_bindings(host_ips, publish(gateway_port), gateway_port)),
])
# The gateway calls out to agents, as a trigger's executor, through the host.
gateway_extra_hosts = [" extra_hosts:", *(f' - "{entry}"' for entry in extra_hosts)] if extra_hosts else []
lines.extend([
" environment:",
*gateway_env,
*exposed_gateway_ports,
*gateway_extra_hosts,
" restart: on-failure",
" healthcheck:",
f' test: ["CMD-SHELL", "python3 -c \\"import socket; s=socket.create_connection((\'localhost\',{gateway_port}),2); s.close()\\""]',
Expand Down Expand Up @@ -704,6 +708,7 @@ async def _stand_up(_spec):
state_instance=state_instance,
host_port=sandbox.host_port,
host_ips=sandbox.host_ips,
extra_hosts=sandbox.extra_hosts,
mcp_server_name=mcp_server_name,
)
logger.info(f"Generated docker-compose.yml:\n{_redact_compose_secrets(compose_content)}")
Expand Down
43 changes: 37 additions & 6 deletions src/agent_env/providers/sandbox_providers/local_sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@
import socket
import subprocess
import tempfile
from ipaddress import ip_address
from pathlib import Path
from typing import TYPE_CHECKING, Any, Optional
from urllib.parse import urlsplit, urlunsplit
from uuid import uuid4

from agent_env import config
Expand All @@ -42,6 +44,11 @@

logger = logging.getLogger(__name__)

# The name a container on this machine reaches it by. Only Linux lacks it natively; on Rancher Desktop an explicit
# mapping would point it at the VM.
_HOST_ALIAS = "host.docker.internal"
_EXTRA_HOSTS = (f"{_HOST_ALIAS}:host-gateway",) if platform.system() == "Linux" else ()

_APP_PATH_PATTERN = re.compile(r"(?<![A-Za-z0-9_./~})$-])/app(?=(?:/|:|[\s'\";)&|]|$))")
_IN_CONTAINER_SCRIPT = re.compile(r"\s*(?:sudo\s+)?docker\s+exec\b")

Expand Down Expand Up @@ -116,6 +123,7 @@ class LocalSandbox(VmSandbox):
"""

type = "local"
extra_hosts = _EXTRA_HOSTS

def __init__(self, exposed_ports: list[int] | None = None, work_dir: Path | None = None, sandbox_id: str | None = None,
port_map: dict[int, int] | None = None):
Expand Down Expand Up @@ -368,8 +376,7 @@ def remove_local_work_dir(sandbox_id: str) -> Path | None:
class LocalSandboxProvider(SandboxProvider):
"""SandboxProvider that runs VM-style gateway deployments on local Docker."""

# Only Linux lacks a native host.docker.internal; on Rancher Desktop this mapping would point it at the VM.
EXTRA_CONTAINER_RUN_ARGS = "--add-host host.docker.internal:host-gateway" if platform.system() == "Linux" else ""
EXTRA_CONTAINER_RUN_ARGS = " ".join(f"--add-host {entry}" for entry in _EXTRA_HOSTS)

async def create_vm(
self,
Expand Down Expand Up @@ -492,10 +499,34 @@ def shares_network_with(cls, sandbox_type: Optional[str]) -> bool:

@classmethod
def get_external_url(cls, url: str) -> str:
"""Rewrite a ``localhost`` URL (a published host port) to the form a workload reaches from
inside a container: ``host.docker.internal``. Docker Desktop and Rancher Desktop (macOS/Windows)
resolve this themselves; on Linux ``EXTRA_CONTAINER_RUN_ARGS`` maps it via ``--add-host …:host-gateway``."""
return url.replace("localhost", "host.docker.internal").replace("127.0.0.1", "host.docker.internal")
"""``url`` as a container on this machine reaches it: a loopback host (a published host port, or a
service on this machine) becomes ``host.docker.internal``, and anything else is left alone. Docker
Desktop and Rancher Desktop resolve that name themselves; on Linux ``extra_hosts`` maps it to the
bridge gateway, so a service there must listen on more than loopback."""
parts = urlsplit(url)
if not _is_loopback(parts.hostname):
return url
userinfo, at, _ = parts.netloc.rpartition("@")
port = f":{parts.port}" if parts.port is not None else ""
return urlunsplit(parts._replace(netloc=f"{userinfo}{at}{_HOST_ALIAS}{port}"))


def host_url_for(url: str, sandbox_type: Optional[str]) -> str:
"""``url``, which this machine reaches, as a container on ``sandbox_type`` reaches it: only a local
sandbox's containers run on this machine, and they reach its loopback by another name."""
return LocalSandboxProvider.get_external_url(url) if sandbox_type == LocalSandbox.type else url


def _is_loopback(host: Optional[str]) -> bool:
"""Whether ``host`` names this machine from itself: ``localhost``, a loopback address, or the unspecified
address a server listening everywhere prints."""
if host == "localhost":
return True
try:
address = ip_address(host or "")
except ValueError:
return False
return address.is_loopback or address.is_unspecified


@functools.cache
Expand Down
2 changes: 2 additions & 0 deletions src/agent_env/providers/sandbox_providers/sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,8 @@ class Sandbox(ABC):
network_policy: NetworkPolicy | None = None
# Host IPs published ports bind to; empty binds every interface.
host_ips: tuple[str, ...] = ()
# ``name:address`` entries the containers started on this sandbox add to their hosts file.
extra_hosts: tuple[str, ...] = ()

_VM_READY_TIMEOUT = 1200 # wait_for_vm wall-clock budget (s)
_VM_READY_POLL_INTERVAL = 30 # sparse polling (s)
Expand Down
13 changes: 13 additions & 0 deletions src/agent_env/task_step/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
from typing import Any

from agent_env.env.env import DeployedEnv
from agent_env.providers.sandbox_providers.local_sandbox import LocalSandbox, host_url_for
from agent_env.providers.sandbox_providers.sandbox_provider import reachable_url
from agent_env.utils.deprecation import warn_deprecated

_REDACTED_KEYS = {
Expand Down Expand Up @@ -81,6 +83,17 @@ def from_dict(cls, data: dict) -> DeployedAgent:
on_host=bool(data.get("on_host", False)),
)

def url_for(self, sandbox_type: str | None, *, on_host: bool = False) -> str:
"""This agent's A2A URL as an agent on ``sandbox_type`` reaches it, in a container there or, ``on_host``, on
the sandbox's host. A local sandbox's host is this machine, which reaches the URL as it is. An agent outside
our sandboxes, a human say, is at a URL this machine reaches."""
url = self.a2a_url or self.api_url
if on_host and sandbox_type == LocalSandbox.type:
return url
if not self.sandbox_id:
return host_url_for(url, sandbox_type)
return reachable_url(url, from_sandbox_type=self.sandbox_type, to_sandbox_type=sandbox_type)


@dataclass
class DeployedSandbox:
Expand Down
6 changes: 5 additions & 1 deletion src/agent_env/task_step/task_steps/install_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

import httpx

from agent_env.providers.sandbox_providers.local_sandbox import host_url_for
from agent_env.task_step.context import DeployedAgent, TaskStepContext
from agent_env.entity_refs import EntityRef
from agent_env.task_step.task_step import TaskStep, TaskStepDependency
Expand All @@ -43,6 +44,7 @@ def _build_param_resolvers(
agent_name: str,
workspace_dir: Optional[str] = None,
litellm_api_key_override: Optional[str] = None,
sandbox_type: Optional[str] = None,
) -> dict[str, Callable[[], str]]:
"""Per-install registry: required_params name -> zero-arg resolver returning the raw value.

Expand Down Expand Up @@ -81,7 +83,7 @@ def _resolve_container() -> str:
"workspace_dir": _resolve_workspace_dir,
# Config-sourced (from agent-env config)
"litellm_api_key": lambda: litellm_api_key_override or config.get_litellm_api_key(),
"litellm_base_url": lambda: config.get_litellm_base_url(),
"litellm_base_url": lambda: host_url_for(config.get_litellm_base_url(), sandbox_type),
}


Expand Down Expand Up @@ -240,6 +242,8 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext:
workspace_dir=self.workspace_dir,
agent_name=self.agent_name,
litellm_api_key_override=user_overrides.get("litellm_api_key"),
# A host install on a local sandbox runs on this machine itself, not in a container.
sandbox_type=None if host_mode else sandbox.type,
)
resolved: dict[str, str] = {}
for name in required_params:
Expand Down
4 changes: 3 additions & 1 deletion src/agent_env/task_step/task_steps/load_artifact.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from typing import Any, Callable, ClassVar, Optional
from urllib.parse import unquote, urlparse

from agent_env.providers.sandbox_providers.sandbox_provider import reachable_url
from agent_env.task_step.context import TaskStepContext
from agent_env.entity_refs import EntityRef
from agent_env.task_step.task_step import TaskStep, TaskStepDependency
Expand Down Expand Up @@ -490,7 +491,8 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext:
if agent is None or not agent.instance_id:
raise RuntimeError(f"Agent '{self.agent_name}' not found in context.deployed_agents (or missing instance_id)")
deployed_agent = get_a2a_agent_instance_store().get(agent.instance_id)
gateway_url = require_gateway_url(deployed, "Installing a CliArtifact")
gateway_url = reachable_url(require_gateway_url(deployed, "Installing a CliArtifact"),
from_sandbox_type=deployed.sandbox_type, to_sandbox_type=agent.sandbox_type)
install_path = await A2AAgent.install_cli(deployed_agent, artifact, gateway_url)
agent_clis = context.metadata.setdefault("installed_clis", {}).setdefault(self.agent_name, {})
agent_clis[artifact.id] = {
Expand Down
8 changes: 5 additions & 3 deletions src/agent_env/task_step/task_steps/peer_agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,14 +79,16 @@ async def _post(self, deployed_by_name: dict[str, DeployedAgent], peering: Agent
a2a_url = src.a2a_url or src.api_url
ext = a2a_agent_cls.find_extension(src.a2a_card or {}, a2a_agent_cls.EXT_PEER_AGENTS)
endpoint = (ext.get("params") or {}).get("endpoint", "/ext/peer-agents")
peers = [self._record(deployed_by_name[n]) for n in peering.peer_agent_names]
peers = [self._record(deployed_by_name[n], src) for n in peering.peer_agent_names]
async with httpx.AsyncClient() as client:
resp = await client.post(a2a_url + endpoint, json={"peers": peers}, timeout=_POST_TIMEOUT_SECONDS)
if resp.status_code >= 400:
raise RuntimeError(f"PeerAgentsTaskStep: POST {a2a_url + endpoint} failed for '{peering.source_agent_name}': {resp.status_code} {resp.text}")
logger.info("PeerAgentsTaskStep: peered '%s' with %s", peering.source_agent_name, peering.peer_agent_names)

@staticmethod
def _record(peer: DeployedAgent) -> dict:
def _record(peer: DeployedAgent, source: DeployedAgent) -> dict:
"""``peer``'s entry in ``source``'s table."""
card = peer.a2a_card or {}
return {"name": peer.agent_name, "url": peer.a2a_url or peer.api_url, "card": card, "description": card.get("description")}
url = peer.url_for(source.sandbox_type, on_host=source.on_host)
return {"name": peer.agent_name, "url": url, "card": card, "description": card.get("description")}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import httpx

from agent_env.env.env import DeployedSandboxEnv
from agent_env.env.gateway.constants import EXT_TRIGGERS_URI
from agent_env.task_step.context import TaskStepContext
from agent_env.entity_refs import EntityRef
Expand Down Expand Up @@ -80,7 +81,10 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext:
raise RuntimeError(
f"Executor agent role '{agent.role}' is in watch_roles {self.watch_roles} — "
f"the executor's own tool calls would fire triggers (cascade).")
body["executor"] = {"a2a_url": agent.a2a_url or agent.api_url,
# The env's gateway calls the executor; an env outside our sandboxes gets its URL as it is.
executor_url = (agent.url_for(deployed.sandbox_type) if isinstance(deployed, DeployedSandboxEnv)
else agent.a2a_url or agent.api_url)
body["executor"] = {"a2a_url": executor_url,
"timeout_seconds": self.executor_timeout_seconds,
"role": agent.role}
try:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -218,10 +218,11 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext:
device_flags = " ".join(f"--device {shlex.quote(d)}" for d in self.devices)
cap_flags = " ".join(f"--cap-add {shlex.quote(c)}" for c in self.cap_add)
volume_flags = " ".join(f"-v {shlex.quote(v)}" for v in self.volumes)
host_flags = " ".join(f"--add-host {shlex.quote(entry)}" for entry in sandbox.extra_hosts)
extra_flags = " ".join(f for f in (
"--privileged" if self.privileged else "",
f"--shm-size {shlex.quote(self.shm_size)}" if self.shm_size else "",
device_flags, cap_flags, volume_flags,
device_flags, cap_flags, volume_flags, host_flags,
) if f)
entrypoint_flag = ""
if self.command_override:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
import uuid
from typing import Any, ClassVar, Optional

from agent_env.providers.sandbox_providers.local_sandbox import host_url_for
from agent_env.providers.sandbox_providers.sandbox_provider import (
all_sandbox_container_env,
registered_sandbox_provider_classes,
Expand Down Expand Up @@ -66,6 +67,7 @@
"LITELLM_BASE_URL", "ANTHROPIC_BASE_URL",
"PATH", "HOME",
})
_MODEL_URL_KEYS = frozenset({"LITELLM_BASE_URL", "ANTHROPIC_BASE_URL"})


class RunContainerUnitTestsVerifierTaskStep(TaskStep):
Expand Down Expand Up @@ -253,6 +255,8 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext:
command, extra_env = self._resolve_command(context)
merged_env.update(self.env_vars)
merged_env.update(extra_env)
for key in _MODEL_URL_KEYS & merged_env.keys():
merged_env[key] = host_url_for(merged_env[key], sandbox.type)
env_flags = " ".join(f"-e {k}={shlex.quote(v)}" for k, v in merged_env.items())

# 3. Run setup_commands (fail loud)
Expand Down
Loading
Loading