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 @@ -122,7 +122,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/`, among them `start_computer_recording` and `end_computer_recording`, and more). On an env whose card serves computer-exec/v1 (a computer), `run_code` with `env_id` and `run_container_unit_tests_verifier` with `env_id` run inside it as its session user through the driver: files staged with `data/add` and read with `data/get`, commands run with `run`; `env_outcome_verifier` hands a `verify` that takes an `env_url` keyword the env's address. A computer's framework-only routes (everything but MCP and the card) take the per-deploy control token its provider boots it with (`DeployedEnv.control_token`, `AGENTENV_CONTROL_TOKEN`); framework calls send `deployed.control_headers`, `Task.run` runs every step under `protocol_v1.authorized` with each deployed env's (a `verify` included), and a `run_code` script inside the computer gets `input["env"]["headers"]`. `teardown_run` and the `teardown_sandboxes` step share one core (`task/teardown.py` `tear_down`): the recordings the run left open end with fresh grants, each env still up closes (`Env.close`; a computer env ends any recording still open), what stayed open is registered from its namespace, then every sandbox is terminated by id. `task/episode/` runs a task with some of its agents played from outside, as a training loop does (`EpisodeSpec`). |
| `store/` | Four store ABCs with local and cloud implementations: `DocumentStore` (SQLite, MongoDB, DynamoDB, Firestore with MongoDB compatibility), `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. `store/routing.py` is the local namespaces, `@local` and `@hf` (`is_local_id`), each with a documents file of its own: while the CLI or a `build_app` server runs, a local id and what a local task's run records go to local stores beside the configured ones, and that run's writes to the configured stores are refused. 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), `smol_vm` (the `smol` extra), `tensorlake` (the `tensorlake` extra), `tenki` (the `tenki` extra), `vercel` (the `vercel` extra), `boxd` (the `boxd` extra), `coreweave` (the `coreweave` extra), `daytona` (the `daytona` extra), `boat` (the `boat` extra), `agent37` (no 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`, and for VMs on this machine the address their requests to it arrive at, `REACHES_HOST_AT`; `CONTAINER_ACCEPTS`, `PRIVATE_NETWORK`; `SUPPORTED_SANDBOX_MODES`, derived from whether it implements `create_sandbox` and `create_vm`; and `SANDBOX_ACCEPTS`, the images an agent's sandbox runs, which defaults to those a VM loads when it implements `create_vm` and to `CONTAINER_ACCEPTS` otherwise, so only a provider that implements `create_vm` yet gives an agent a container, like the local one, declares it), 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), `EnvironmentGatewayProvider` (`gateway`, an env's default), which renders a docker-compose for the gateway and its MCP servers inside the sandbox, and `EnvironmentServerProvider` (`server`), one MCP server in its own container with no gateway in front and no state store; `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), `smol_vm` (the `smol` extra), `tensorlake` (the `tensorlake` extra), `tenki` (the `tenki` extra), `vercel` (the `vercel` extra), `boxd` (the `boxd` extra), `coreweave` (the `coreweave` extra), `daytona` (the `daytona` extra), `boat` (the `boat` extra), `agent37` (no 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`, and for VMs on this machine the address their requests to it arrive at, `REACHES_HOST_AT`; `CONTAINER_ACCEPTS`, `PRIVATE_NETWORK`, `WEBSOCKETS`; `SUPPORTED_SANDBOX_MODES`, derived from whether it implements `create_sandbox` and `create_vm`; and `SANDBOX_ACCEPTS`, the images an agent's sandbox runs, which defaults to those a VM loads when it implements `create_vm` and to `CONTAINER_ACCEPTS` otherwise, so only a provider that implements `create_vm` yet gives an agent a container, like the local one, declares it), 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), `EnvironmentGatewayProvider` (`gateway`, an env's default), which renders a docker-compose for the gateway and its MCP servers inside the sandbox, and `EnvironmentServerProvider` (`server`), one MCP server in its own container with no gateway in front and no state store; `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. |
| `openenv/` | `build_app(bundle)`: the [OpenEnv](https://github.com/openenv/OpenEnv) server for a bundle's tasks, each session playing one episode through the Episode API, with the envs' MCP tools, an added `end_turn` tool and the task's verifier score as the reward. It prepares bundles as `agent-env run` does (`prepare_bundle(played=True)`, without the agent the client plays) and keeps namespace routing on for the app's life. `openenv` comes with the `openenv` extra, a floor with no cap; this package and `training/` are the only ones that import it. Each task's description in the Task API names its `toolset` (the played agent's envs, role, and whether it gets `bash`). With `token`, every request and WebSocket but `/health` must carry `Authorization: Bearer <token>`, for a server others reach. |
| `training/` | One module per outside trainer, each behind its own extra, and the modules they share; [Training helpers](#training-helpers) lists them. |
Expand Down
22 changes: 16 additions & 6 deletions src/agent_env/env/envs/computer/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,10 @@ def accept_toml(cls, data: dict, ctx: AuthoringContext) -> dict:
ctx.refuse([*refused, *missing])
return {"environment_name": ctx.name, **fields}

def serves_website_browser(self) -> bool:
"""Whether a website browser is among its apps, whose CDP address an agent reaches by WebSocket."""
return any(app.declaration.browser for app in self.composed_apps())

def composed_apps(self) -> list[ComputerAppEnv]:
"""The computer app envs composed into the env's computer, each installed under its prefix."""
return self.apps
Expand Down Expand Up @@ -255,8 +259,9 @@ async def deploy(self, ttl_seconds: int = 10800, disk_size_gb: float = 10,
def deploy_refusal(self, *, sandbox_type: str | None = None, env_params: Optional[dict] = None,
**options) -> str | None:
"""Why deploy() would refuse before booting anything, or None: an option the computer provider doesn't take, a
problem with ``env_params``, or a sandbox provider that runs none of the env's arches natively or can't enforce
its network."""
problem with ``env_params``, or a sandbox provider with no one link that runs one of the env's arches natively,
carries WebSockets when a website browser is among its apps, and can enforce its network, as a deploy needs of
the link it boots on."""
from agent_env.providers import build_sandbox_provider, get_env_sandbox_provider
from agent_env.providers.sandbox_providers.sandbox import NetworkPolicy

Expand All @@ -266,16 +271,21 @@ def deploy_refusal(self, *, sandbox_type: str | None = None, env_params: Optiona
sandbox_provider = build_sandbox_provider(sandbox_type) if sandbox_type else get_env_sandbox_provider()
links, arches = sandbox_provider.links, self.composed_arches()
forms = [params.form] if params.form else gate.forms(self.computer)
if not any(boot.native_links(sandbox_provider, arch, form, self.stored_id) for form in forms
for arch in arches):
native = [link for link in links if any(boot.native_links(link, arch, form, self.stored_id) for form in forms
for arch in arches)]
usable = [link for link in native if link.WEBSOCKETS] if self.serves_website_browser() else native
if native and not usable:
problems.append(f"{', '.join(type(link).__name__ for link in native)} carries no WebSockets, which its "
"website browser's CDP address needs")
if not native:
unpulled = [type(link).__name__ for link in links if not boot.pulls_containers(link, self.stored_id)]
problems.append(f"{', '.join(type(link).__name__ for link in links)} runs none of its arches "
f"({', '.join(arches)}) natively as {' or '.join(forms)}" + (
f", and {', '.join(unpulled)} can't pull its containers from the image store's registry "
"on this machine" if "container" in forms and unpulled else ""))
policy = NetworkPolicy.from_dict(self.network) if self.network else None
if policy is not None and not any(link.supports_network_policy(policy) for link in links):
problems.append(f"{', '.join(type(link).__name__ for link in links)} can't enforce its network")
if policy is not None and usable and not any(link.supports_network_policy(policy) for link in usable):
problems.append(f"{', '.join(type(link).__name__ for link in usable)} can't enforce its network")
return f"computer env {self.id!r}: " + "; ".join(problems) if problems else None

@classmethod
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,9 @@ async def _boot(self, env: ComputerEnv, builds: list[tuple[str, str, dict]], par
if (picked := first_native(sandbox_provider, pairs, env.stored_id)) is None:
raise ValueError(f"{type(sandbox_provider).__name__} runs none of {env.id}'s ready builds natively "
f"({', '.join(f'{form}/{arch}' for form, arch, _ in builds)})")
if env.serves_website_browser() and not sandbox_provider.WEBSOCKETS:
raise ValueError(f"{type(sandbox_provider).__name__} carries no WebSockets, which {env.id}'s website "
"browser's CDP address needs")
form, arch = picked
build = next(build for name, at, build in builds if (name, at) == picked)
if refusal := env.disk_size_refusal(form, disk_size_gb):
Expand Down
3 changes: 2 additions & 1 deletion src/agent_env/providers/sandbox_providers/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,14 +78,15 @@ with `exposed_ports=[port]` and returns the VM, and the deploy then loads and st
### Declarations

Class variables the rest of agent-env reads instead of asking which provider it has. A provider that declares nothing has its
sandboxes off this machine, runs containers' images by name, and has no private network.
sandboxes off this machine, runs containers' images by name, has no private network, and isn't trusted to carry WebSockets.

| Variable | Set it when |
|---|---|
| `ON_THIS_MACHINE`, `REACHES_HOST_AT` | the sandboxes run on this machine (local Docker, a local VM) |
| `CONTAINER_ACCEPTS` | `create_container` runs images other than by name: `Accepts.LOADABLE` (tar.gz, registry name or build context), `Accepts.NAME_OR_CONTEXT` |
| `SANDBOX_ACCEPTS` | an agent's sandbox runs different images than the default (a provider that implements `create_vm` defaults to `LOADABLE`) |
| `PRIVATE_NETWORK` | `create_container(private_network=True)` puts containers on a network they share |
| `WEBSOCKETS` | its sandboxes' exposed ports carry WebSockets both ways, as our own tests or its tunnel's documentation show; a computer with a website browser among its apps, whose CDP address agents reach by WebSocket, refuses a provider without it at preflight, and a chain skips it at deploy |

## Sandbox handle contract

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ class Agent37SandboxProvider(SandboxProvider):

# Exposed ports get public URLs under this domain.
EGRESS_HOSTS: ClassVar[tuple[str, ...]] = ("*.agent37.app",)
WEBSOCKETS = True

def __init__(self, *, api_key: str, region: str = "us", transport: httpx.AsyncBaseTransport | None = None):
if not isinstance(api_key, str) or not api_key.strip():
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ class BoxdSandboxProvider(SandboxProvider):

# Exposed ports get public URLs under this domain.
EGRESS_HOSTS: ClassVar[tuple[str, ...]] = ("*.boxd.sh",)
WEBSOCKETS = True

def __init__(
self,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -648,6 +648,7 @@ class LocalSandboxProvider(SandboxProvider):
# a VM it creates (create_vm) loads images.
SANDBOX_ACCEPTS = Accepts.NAME_OR_CONTEXT
CONTAINER_ACCEPTS = Accepts.NAME_OR_CONTEXT
WEBSOCKETS = True

@property
def _prepared(self) -> set[str]:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ class LumeSandboxProvider(SandboxProvider):

ON_THIS_MACHINE: ClassVar[bool] = True
REACHES_HOST_AT: ClassVar[str] = "127.0.0.1"
WEBSOCKETS = True

def __init__(self, *, ssh_user: str = "lume", ssh_password: str = "lume", max_running: int = MAX_RUNNING,
relay_host: str = "127.0.0.1", lume: str = "lume", ssh: str = "/usr/bin/ssh",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ class ModalSandboxProvider(SandboxProvider):
# containers share i6pn, Modal's private network.
CONTAINER_ACCEPTS = Accepts.NAME_OR_CONTEXT
PRIVATE_NETWORK = True
WEBSOCKETS = True

@classmethod
def supports_network_policy(cls, policy: NetworkPolicy) -> bool:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,7 @@ class ModalVmSandboxProvider(SandboxProvider):
"""Provisions a single Modal VM sandbox per env (the VM-mode Modal backend), or one booting a computer's disk."""

EGRESS_HOSTS: ClassVar[tuple[str, ...]] = ("*.modal.host", "*.w.modal.host")
WEBSOCKETS = True

@classmethod
def supports_network_policy(cls, policy: NetworkPolicy) -> bool:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ class SailVmSandboxProvider(SandboxProvider):
Sail adds it to the agent's requests to the model endpoint (see ``model_key``)."""

EGRESS_HOSTS: ClassVar[tuple[str, ...]] = ("*.sail.box",)
WEBSOCKETS = True

def __init__(
self,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,9 @@ def from_config(cls, **config: Any) -> Self:
_DECLARES_SANDBOX_ACCEPTS: ClassVar[bool] = False
# Whether create_container(private_network=True) puts containers on a network they share.
PRIVATE_NETWORK: ClassVar[bool] = False
# Whether its sandboxes' exposed ports carry WebSockets both ways, as a website browser's CDP address needs: on
# where our own tests or its tunnel's documentation say so.
WEBSOCKETS: ClassVar[bool] = False

def __init_subclass__(cls, **kwargs: Any) -> None:
super().__init_subclass__(**kwargs)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ class SmolVmSandboxProvider(SandboxProvider):
_LOCAL_REGISTRY_DEFAULT_PORT: ClassVar[int] = 5000
ON_THIS_MACHINE: ClassVar[bool] = True
REACHES_HOST_AT: ClassVar[str] = "127.0.0.1"
WEBSOCKETS = True
CREATES_VMS: ClassVar[bool] = True
SANDBOX_ACCEPTS: ClassVar[Accepts] = Accepts.LOADABLE
CONTAINER_ACCEPTS: ClassVar[Accepts] = Accepts.NAME
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ class TenkiSandboxProvider(SandboxProvider):

# Exposed ports get public URLs under these domains. Tenki wildcards cover one label, hence the regional entry.
EGRESS_HOSTS: ClassVar[tuple[str, ...]] = ("*.sb.tenki.sh", "*.us.sb.tenki.sh")
WEBSOCKETS = True

def __init__(
self,
Expand Down
Loading
Loading