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 @@ -39,6 +39,11 @@ agent_default = "local" # agents too
# [sandbox.providers.boat.config]
# api_key = "secret:boat_api_key" # or "env:BOAT_API_KEY"

# Optional Agent37 VMs (no extra needed):
# [sandbox.providers.agent37.config]
# api_key = "secret:agent37_api_key" # or "env:AGENT37_API_KEY"
# region = "us" # or "eu", "ap-south"

# Optional CoreWeave Sandboxes (install the 'coreweave' extra):
# [sandbox.providers.coreweave.config]
# api_key = "secret:cwsandbox_api_key" # or wandb_api_key, with optional wandb_entity and wandb_project
Expand Down
8 changes: 4 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,9 @@ or one of them only where it is used.
store, an OCI registry at `localhost:5000`, env-var secret store, `LocalRunner`, `local` Docker
sandboxes for envs and agents. MongoDB, DynamoDB, Firestore, S3, Cloud Storage, ECR, AWS Secrets
Manager, Google Cloud Secret Manager, and the Modal, E2B, Sail, SmolVM, Tensorlake, Tenki, Vercel, boxd,
CoreWeave, Daytona and boat sandboxes exist as implementations and are selected by config (the last nine
through their extras: `sail`, `smol`, `tensorlake`, `tenki`, `vercel`, `boxd`, `coreweave`, `daytona`, `boat`;
every extra but `smol` is in `dev`).
CoreWeave, Daytona, boat and Agent37 sandboxes exist as implementations and are selected by config (all but
Agent37 through their extras: `sail`, `smol`, `tensorlake`, `tenki`, `vercel`, `boxd`, `coreweave`, `daytona`,
`boat`; every extra but `smol` is in `dev`; Agent37 needs only `httpx`).
- The model endpoint is unset until `[model] base_url` / `api_key` (or `LITELLM_BASE_URL` /
`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
Expand Down 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 `website_state_verifier`, which reads each checked session of a deployed website env's sites, or a multi env's, at the site's `/go?sid=` through `/svc/<name>/` and checks one dot path of its `current_state`, `initial_state` or `state_diff` each, optionally reduced by `len`, `keys` or `bool`, with `eq`, `ne`, `ge`, `gt`, `le`, `lt`, `in` or `contains`, its sites checked before a run against the `env_version` it pins, `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); `[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`; `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
2 changes: 1 addition & 1 deletion src/agent_env/cli/a2a_agent/deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
@click.option("--ttl-seconds", type=click.IntRange(min=MIN_TTL_SECONDS, max=MAX_TTL_SECONDS), default=DEFAULT_TTL_SECONDS,
help=f"VM lifetime in seconds (default {DEFAULT_TTL_SECONDS})")
@click.option("--sandbox", default=None,
help="Sandbox backend(s): a built-in (modal, modal_vm, e2b, tensorlake, tenki, boxd, coreweave, daytona, boat, local) "
help="Sandbox backend(s): a built-in (modal, modal_vm, e2b, tensorlake, tenki, boxd, coreweave, daytona, boat, agent37, local) "
"or a name from [sandbox.providers] in .agentenv/config.toml; comma-separated for a "
"fallback chain. Defaults to [sandbox].agent_default (else local) when omitted.")
def deploy(agent_id: str, agent_version: int | None, env_var_pairs: tuple[str, ...], ttl_seconds: int, sandbox: str):
Expand Down
2 changes: 1 addition & 1 deletion src/agent_env/cli/env/deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
@click.option("--gateway-mode", type=click.Choice([m.value for m in GatewayMode], case_sensitive=False),
default=GatewayMode.PERFORMANCE.value, help="Gateway mode (performance or consistent)")
@click.option("--sandbox", default=None,
help="Sandbox backend(s): a built-in (modal, modal_vm, e2b, tensorlake, tenki, boxd, coreweave, daytona, boat, local) "
help="Sandbox backend(s): a built-in (modal, modal_vm, e2b, tensorlake, tenki, boxd, coreweave, daytona, boat, agent37, local) "
"or a name from [sandbox.providers] in .agentenv/config.toml; comma-separated for a "
"fallback chain. Defaults to [sandbox].default (else local) when omitted.")
@click.option("--service-db", "service_db_env_id", default=None,
Expand Down
140 changes: 140 additions & 0 deletions src/agent_env/providers/sandbox_providers/agent37/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
# `agent37` sandbox provider

Runs agent-env sandboxes on [Agent37](https://www.agent37.com) VMs. Each environment and each agent gets its own
Linux virtual machine with its own kernel, Docker and Compose v2 running inside, and public HTTPS URLs for its ports.
It is a VM provider: agent-env's docker-in-VM flows run on it unchanged. That covers the gateway's docker-compose, an
agent's `docker run`, image loading and artifact collection.

## Set up

### 1. Nothing to install

The provider talks to Agent37's REST API with `httpx`, which agent-env already depends on, so there is no extra.

### 2. Get an Agent37 API key

Sign up at [agent37.com](https://www.agent37.com), add funds (VMs need a funded workspace), and create a key under
**API keys** in the dashboard. A key belongs to one workspace, and VMs are created and billed there. For local work,
export it:

```bash
export AGENT37_API_KEY=sk_live_...
```

For a shared deployment, put it in your secret store instead, for example as `agent37_api_key`, and reference it with
`secret:agent37_api_key`. Never put the key itself in `config.toml`. The provider reads the key only from its config,
never from the process environment.

### 3. Configure

A complete `.agentenv/config.toml` that runs every environment and agent on Agent37, with the local stores:

```toml
[sandbox]
default = "agent37" # environments and sandboxes
agent_default = "agent37" # agents

[sandbox.providers.agent37.config]
api_key = "env:AGENT37_API_KEY" # or "secret:agent37_api_key"

[model]
base_url = "https://litellm.example.com"
api_key = "env:LITELLM_API_KEY"
```

To keep the local default and use Agent37 per run instead, add only the `[sandbox.providers.agent37.config]` table and
pass `--sandbox agent37`, or `--env-sandbox agent37` / `--agent-sandbox agent37` on `agent-env task run`.
`agent-env config show` prints the file in effect and masks the key.

### 4. Run something

The bundled `hello` task deploys a sandbox, loads a file into it and checks it, with no model needed:

```bash
agent-env run hello --sandbox agent37
```

`agent-env -v run …` also logs each VM as it starts
(`Agent37 instance started: id=… name=… region=… resources=… ports=… attribution=…`).

## Configuration reference

All keys go under `[sandbox.providers.agent37.config]`. Unknown keys are refused.

| Key | Default | Meaning |
| --------- | -------- | ------------------------------------------------------------------------------------ |
| `api_key` | required | The Agent37 API key, as an `env:` or `secret:` reference. |
| `region` | `us` | Where VMs run: `us`, `eu` (France) or `ap-south` (India). The price is the same in all three. |

## Resources and lifetime

- **Sizes:** Agent37 VMs come in four shapes: 2 vCPU / 4 GiB, 4 vCPU / 8 GiB, 4 vCPU / 16 GiB and 4 vCPU / 32 GiB.
A request gets the smallest that covers its CPU, memory and disk, so agent-env's default (1 vCPU, 8 GiB, 10 GB) runs
on 4 vCPU / 8 GiB.
- **Disk:** every VM gets its shape's largest disk: 12, 20, 40 or 80 GB. Docker keeps its images and containers on it.
- **Refused requests:** a request beyond 4 vCPU, 32 GiB or 80 GB is refused before anything is created, not shrunk.
- **Ignored requests:** `boot_mode` has no Agent37 equivalent, so a `boot_mode` that is set logs a warning and has no
effect.
- **Lifetime:** Agent37 has no delete-after timer, so the sandbox's `timeout` is not enforced and a VM runs until it is
terminated. agent-env tears its sandboxes down when a run ends; a run that crashes leaves its VMs running. They are
named `agentenv-<random>` and carry the run's attribution as metadata, so they are easy to find in the dashboard or
with `GET https://api.agent37.com/v1/instances`, and `DELETE /v1/instances/{id}` removes one.
- **Idle VMs:** auto-sleep is off, so a VM never sleeps on its own while a run waits on it.

## Networking

- **Ports:** each exposed port is an Agent37 [public port](https://www.agent37.com/docs/agents-api/public-ports), an
`https://<random>.agent37.app` URL that fills `tunnel_urls`. It serves HTTP, streaming responses and WebSocket with
no platform authentication; the random hostname is the only credential.
- **`/health`:** Agent37's edge answers the path `/health` on every URL itself, so a service behind a public port never
sees a request to exactly `/health`. Every other path reaches it.
- **Egress:** allow-all only. Agent37 can restrict a VM to a list of IPv4 addresses and ranges but not to hostnames, so
an allowlist is refused with `NetworkPolicyUnsupportedError` before anything is created, and a fallback chain skips
to its next provider.
- **Agent37's own hosts:** `*.agent37.app` is added to every other provider's allowlists, so a restricted agent
elsewhere can reach an environment running here.
- **Image and object downloads:** with a store that signs URLs (S3, Cloud Storage), the VM fetches objects and image
tarballs itself. Everything in the local stores goes over exec from this machine instead, which runs at this
machine's upload speed.
- **Reconnect:** `get_sandbox` restores the ports and the applied egress policy.

## Attribution and cost

agent-env's attribution, including `[sandbox.attribution]` defaults, is stored as the instance's `metadata`, so the
dashboard and `GET /v1/instances` show which run each VM belongs to. VMs are billed per minute from the workspace's
prepaid balance while they run, and deleting one ends its billing.

## Cleanup

- A VM whose setup fails after it is created (Docker never readies) is deleted.
- If the caller is cancelled while the create is in flight, the provider deletes the VM that create makes once it
answers.
- If a create fails after Agent37 may have made the VM, the provider finds it by its unique name and deletes it.
- `terminate` is idempotent: a VM that is already gone is not an error.

## Things to know

- **Commands run as root,** so the `sudo` agent-env prefixes is dropped.
- **Commands read no input:** each runs with an empty standard input.
- **Long commands:** Agent37 stops a command after 15 minutes, with exit code 137. Start anything longer in the
background (`nohup … > log 2>&1 &`) and poll it.
- **Resends:** each command carries a random run name, and a connection lost mid-command is resent under it. Agent37
answers a resend with the first run's result, waiting for it if it is still running, so a command never runs twice.
- **Lost connections:** when no resend gets an answer, `exec` returns exit code -1. `exec_script` retries idempotent
scripts on -1 only.
- **Output:** exec returns at most 512 KB of each of stdout and stderr, and logs a warning when it cuts one. Copying a
file off the VM to a store that cannot sign URLs reads it 4 MiB at a time as base64 through stdout, which is past that
cap, so that copy fails rather than return a truncated file; use a store that signs URLs (S3, Cloud Storage) to
collect snapshots and images built on the VM.
- **Images:** `create_vm(image=...)` is refused. VMs boot Agent37's `agent37-ubuntu` image (Ubuntu with Docker, Compose
and buildx, with `dockerd` keeping its data under `/root/docker`), and OCI images run under Docker inside them.
- **Platform:** linux/amd64. GPUs are not supported.
- **The model key enters the sandbox:** an agent's `LITELLM_API_KEY` is passed in, as on other providers.

## Testing

`tst/integration/providers/sandbox_providers/agent37_sandbox_smoke_test.py` runs against a real Agent37 workspace when
`[sandbox.providers.agent37.config]` resolves, for example with `make int-test-slow`. Otherwise it skips with
`agentenv-capability-missing: remote_sandbox`. The unit tests are
`tst/unit/providers/sandbox_providers/agent37_sandbox_provider_test.py` and `agent37_sandbox_test.py`, and run offline
against an in-memory Agent37 API behind an `httpx.MockTransport`.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Agent37 VM sandbox provider; see README.md."""
Loading
Loading