Skip to content
Draft
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. `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), `freestyle`; `[sandbox] default` and `agent_default` accept a comma-separated fallback chain. `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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ uv sync --extra dev
source .venv/bin/activate
```

## Freestyle sandboxes

Freestyle is available as the `freestyle` sandbox backend for environments and agents.
It boots Docker-capable VM snapshots, exposes requested ports over HTTPS, and supports
file transfer, reconnection, and teardown. See the [Freestyle provider guide](src/agent_env/providers/sandbox_providers/freestyle/README.md)
for configuration, resource sizing, and command/network limits.

## Plugins

A package of your own can add env types, providers, task steps and stores to agent-env, and `agent-env plugin` lists, checks, adds and removes installed plugins. The [plugin docs](https://www.agentenvframework.com/docs/plugins) show how.
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, local) "
help="Sandbox backend(s): a built-in (modal, modal_vm, e2b, freestyle, 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, local) "
help="Sandbox backend(s): a built-in (modal, modal_vm, e2b, freestyle, 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
61 changes: 61 additions & 0 deletions src/agent_env/providers/sandbox_providers/freestyle/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Freestyle VM sandboxes

Select `freestyle` for environments, agents, or a fallback chain. The provider uses
the Freestyle v5 HTTP API through the existing `httpx` dependency; no extra SDK is needed.

```toml
[sandbox]
default = "freestyle"
agent_default = "freestyle"

[sandbox.providers.freestyle.config]
api_key = "env:FREESTYLE_API_KEY" # or secret:freestyle_api_key
snapshot_id = "freestyle/ubuntu"
# exec_timeout_seconds = 300
# api_url = "https://beta-api.freestyle.sh"
```

Get a key through the [Freestyle dashboard](https://dash.freestyle.sh) or the
[Freestyle CLI](https://www.freestyle.sh/docs/cli). Set it in your environment or secret
store; the provider resolves it from config and never injects it into the guest.
Then use `agent-env env deploy <env-id> --sandbox freestyle` or select the backend in config.

The snapshot must contain Bash, Docker, Docker Compose v2, curl, and the usual GNU
utilities. `freestyle/ubuntu` supplies those. To pin a prepared environment, use its
`sh-…` snapshot id; public snapshot names can be updated by Freestyle. `create_vm(image=…)`
selects another snapshot, and `boot_mode` overrides are rejected.

CPU requests round up to whole vCPUs, memory is MiB, and disk requests are converted
from GiB to MiB. A VM starts with its snapshot's resources; requested dimensions grow
it when larger. Requests below the snapshot's minimum retain that minimum, because
Freestyle resizing only grows resources. `timeout` becomes an absolute VM lifetime
(`ttlSeconds`), including provisioning. Always terminate completed sandboxes.

Each requested port receives its own public HTTPS `style.dev` URL through a TLS
rule created with the VM. Deleting the VM deletes those rules. Reconnection reads
the VM and its TLS rules to recover published ports; it reports an unknown network
policy rather than assuming that the firewall has not changed.

This adapter supports `allow_all` egress only. Freestyle's firewall matches addresses
and VM/network identities, while agent-env's allowlists include hostnames. Restricted
policies are rejected before provisioning; fallback chains can select another backend.
`*.style.dev` joins the framework's platform egress hosts so other restricted sandboxes
can reach a Freestyle environment. See the [firewall docs](https://www.freestyle.sh/docs/vms/network/firewall).

Commands run as root through the buffered exec API. Each command has a maximum
five-minute runtime (`exec_timeout_seconds`, 1–300); a guest timeout reports exit 124
and is not retried. Run longer services in Docker or as background guest processes.
Live stdout/stderr streaming is not supplied by this adapter. File writes use the
binary guest-filesystem API; shared VM helpers handle image artifacts and container files.
Attribution is forwarded as metadata and must fit Freestyle's 64-entry limit with
keys and values at most 63 characters; `freestyle.sh/` keys are reserved.

With credentials configured, run the real provider smoke test:

```bash
.venv/bin/python -m pytest tst/integration/providers/sandbox_providers/freestyle_sandbox_smoke_test.py -v
```

It creates a Docker container, checks HTTPS and container-file writes, verifies a
binary host-file checksum, reconnects, and confirms deletion. Its VM has a 900-second
TTL and is terminated in `finally`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
"""Freestyle sandbox backend."""

from agent_env.providers.sandbox_providers.freestyle.provider import (
FreestyleSandboxProvider,
)
from agent_env.providers.sandbox_providers.freestyle.sandbox import FreestyleSandbox

__all__ = ["FreestyleSandbox", "FreestyleSandboxProvider"]
48 changes: 48 additions & 0 deletions src/agent_env/providers/sandbox_providers/freestyle/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""The Freestyle v5 HTTP operations used by the sandbox adapter."""

from __future__ import annotations

from typing import Any
from urllib.parse import quote

import httpx


class FreestyleClient:
def __init__(self, *, api_key: str, api_url: str):
url = httpx.URL(api_url)
if (
url.scheme != "https"
or not url.host
or url.userinfo
or url.query
or url.fragment
):
raise ValueError(
"Freestyle api_url must be an HTTPS origin without credentials, query or fragment"
)
self._api_key = api_key
self._api_url = str(url).rstrip("/")
self._client: httpx.AsyncClient | None = None

async def request(self, method: str, path: str, **kwargs: Any) -> dict:
if self._client is None:
self._client = httpx.AsyncClient(
base_url=self._api_url,
headers={"Authorization": f"Bearer {self._api_key}"},
timeout=httpx.Timeout(330, connect=30),
)
response = await self._client.request(method, path, **kwargs)
response.raise_for_status()
if response.status_code == 204 or not response.content:
return {}
return response.json()

async def close(self) -> None:
if self._client is not None:
await self._client.aclose()
self._client = None


def vm_path(vm_id: str) -> str:
return f"/v5/vms/{quote(vm_id, safe='')}"
Loading