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
4 changes: 4 additions & 0 deletions .agentenv/config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ agent_default = "local" # agents too
# snapshot = "agentenv-docker" # boot every sandbox from this Docker-capable snapshot instead of building
# # the host image; a request bigger than the snapshot is refused

# Optional boat.dev sandboxes (install the 'boat' extra):
# [sandbox.providers.boat.config]
# api_key = "secret:boat_api_key" # or "env:BOAT_API_KEY"

# [agents]
# default_a2a_agent_id = "my-agent" # what a deploy_agent step that names no agent deploys; by default the
# # agents bundle's Claude Code, @local/agentenv-framework/agents/claude-code
Expand Down
9 changes: 5 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,10 @@ or one of them only where it is used.
- Defaults are local and carry no external coordinates: SQLite document store, filesystem object
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 and
Daytona sandboxes exist as implementations and are selected by config (the last seven through their extras:
`sail`, `smol`, `tensorlake`, `tenki`, `vercel`, `boxd`, `daytona`; every extra but `smol` is in `dev`).
Manager, Google Cloud Secret Manager, and the Modal, E2B, Sail, SmolVM, Tensorlake, Tenki, Vercel, boxd,
Daytona and boat sandboxes exist as implementations and are selected by config (the last eight through their
extras: `sail`, `smol`, `tensorlake`, `tenki`, `vercel`, `boxd`, `daytona`, `boat`; every extra but `smol` is in
`dev`).
- 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 @@ -110,7 +111,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/`, `start_computer_recording` and `end_computer_recording`, whose open recordings `teardown_run` and `teardown_sandboxes` end first, and more). `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` namespace: while the CLI or a `build_app` server runs, an `@local/…` id and what an `@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), `daytona` (the `daytona` 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), `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). |
| `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/trl.py` 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`). |
| `training/` | One module per outside trainer, each behind its own extra, and `_tools.py`, which turns an env's tools into typed, documented Python methods from their JSON Schema, as transformers' `get_json_schema` reads them. `trl.py` (`trl` extra): `AgentEnv(url)` over a server `openenv/` makes, with `dataset` (a row per task) and `factory` (TRL's `environment_factory`: a generated class per toolset whose methods are the tools, a background `reset` and an owned `get_reward`); `AgentEnv.local(bundle)` serves the bundle in process. |
Expand Down
9 changes: 8 additions & 1 deletion THIRD_PARTY_NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ marks, or contributor names to endorse or promote Scale AI, or related products.

## Python dependencies

The runtime dependencies of `agentenv-framework` and its `aws`, `gcp`, `sail`, `tensorlake`, `tenki`, `boxd`, `daytona`, `openenv` and `trl` extras, at the versions resolved in `uv.lock`. Development-only dependencies are not listed.
The runtime dependencies of `agentenv-framework` and its `aws`, `gcp`, `sail`, `tensorlake`, `tenki`, `boxd`, `daytona`, `boat`, `openenv` and `trl` extras, at the versions resolved in `uv.lock`. Development-only dependencies are not listed.

### a2a-sdk 0.3.26

Expand Down Expand Up @@ -137,6 +137,13 @@ The runtime dependencies of `agentenv-framework` and its `aws`, `gcp`, `sail`, `
- Source: <https://github.com/jab/bidict>
- [License text 76](#license-text-76)

### boat-sdk 1.13.0

- License: MIT
- Copyright: Copyright (c) Ascii
- Source: <https://pypi.org/project/boat-sdk/1.13.0/>
- [License text 4](#license-text-4)

### boto3 1.43.38

- License: Apache-2.0
Expand Down
7 changes: 6 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,13 @@ boxd = [
daytona = [
"daytona~=0.224.0", # AsyncDaytona sandboxes, sessions, file API, preview links and egress lists validated against 0.224
]
# The boat.dev sandbox provider, selected as the built-in `boat` backend.
boat = [
"boat-sdk~=1.13.0", # sandboxes, labels, hosted ports, detached commands and their output stream validated against 1.13
"urllib3>=2.1.0,<3", # the SDK's transport, whose errors are what a dropped connection raises
]
dev = [
"agentenv-framework[aws,boxd,daytona,gcp,openenv,sail,tensorlake,tenki,trl,vercel]", # exercise portable optional providers and stores in CI
"agentenv-framework[aws,boat,boxd,daytona,gcp,openenv,sail,tensorlake,tenki,trl,vercel]", # exercise portable optional providers and stores in CI
"fastmcp>=3.4,<4", # the standalone-fastmcp mount path of the protocol; <4 keeps mcp 1.x
"transformers>=5.2", # get_json_schema, which reads agent_env.training's tool methods as TRL shows them to a model
"Pillow>=10.4", # the protocol's computer extra, whose tests the unit tier runs
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, daytona, local) "
help="Sandbox backend(s): a built-in (modal, modal_vm, e2b, tensorlake, tenki, boxd, daytona, boat, 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, daytona, local) "
help="Sandbox backend(s): a built-in (modal, modal_vm, e2b, tensorlake, tenki, boxd, daytona, boat, 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
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""boat sandbox provider: Docker-capable boat.dev sandboxes as agent-env VMs."""
102 changes: 102 additions & 0 deletions src/agent_env/providers/sandbox_providers/boat/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
"""Provider settings, and how an AgentEnv request becomes a boat sandbox's type, name and labels."""

from __future__ import annotations

import logging
import re
import uuid
from collections.abc import Iterable, Mapping
from dataclasses import dataclass, field
from typing import Any

from agent_env.config.errors import ConfigError

logger = logging.getLogger(__name__)

SECTION = "[sandbox.providers.boat.config]"
DEFAULT_API_URL = "https://boat.dev/api/v1"
CONFIG_KEYS = ("api_key", "api_url")

# boat's sandbox types, smallest first: (type, vCPU, GiB of memory, GB of disk).
SIZES = (("small", 2, 4, 51), ("default", 4, 8, 51), ("large", 8, 16, 111), ("xlarge", 16, 32, 251))

MANAGED_LABEL = "agentenv.managed"
PORTS_LABEL = "agentenv.ports"
# Names a sandbox by the create that made it, so a create that failed or was cancelled is found and deleted.
CREATE_LABEL = "agentenv.create"
ATTRIBUTION_LABEL_PREFIX = "agentenv.attr."
MAX_LABELS = 20
LABEL_KEY_MAX = 63
LABEL_VALUE_MAX_BYTES = 256
_NAME_MAX = 120
_UNSAFE_KEY = re.compile(r"[^A-Za-z0-9._/-]+")
_UNSAFE_NAME = re.compile(r"[^A-Za-z0-9-]+")


@dataclass(frozen=True)
class Settings:
"""``[sandbox.providers.boat.config]``, parsed. The key never appears in ``repr``."""

api_key: str = field(repr=False)
api_url: str = DEFAULT_API_URL


def parse_settings(config: Mapping[str, Any]) -> Settings:
"""Parse the provider's config table, refusing anything it does not understand."""
if unknown := sorted(set(config) - set(CONFIG_KEYS)):
raise ConfigError(f"{SECTION} has unknown key(s): {unknown}")
api_key = config.get("api_key")
if not isinstance(api_key, str) or not api_key.strip():
raise ConfigError(f"{SECTION} requires a non-empty 'api_key' (e.g. \"secret:boat_api_key\")")
api_url = config.get("api_url", DEFAULT_API_URL)
if not isinstance(api_url, str) or not api_url.strip():
raise ConfigError(f"{SECTION} 'api_url' must be a non-empty string")
return Settings(api_key=api_key.strip(), api_url=api_url.strip().rstrip("/"))


def sandbox_type(*, cpu: float, memory_mb: int, disk_gb: float) -> str:
"""The smallest boat type that covers the request; a request bigger than the largest is refused."""
for name, vcpu, memory_gib, disk in SIZES:
if cpu <= vcpu and memory_mb <= memory_gib * 1024 and disk_gb <= disk:
return name
name, vcpu, memory_gib, disk = SIZES[-1]
raise ValueError(
f"boat cannot size a sandbox for cpu={cpu}, memory={memory_mb}MB, disk={disk_gb}GB: its largest type, "
f"{name!r}, has {vcpu} vCPU, {memory_gib} GiB memory and {disk} GB disk"
)


def _label_value(value: object) -> str:
"""``value`` cut to boat's limit on a label value, at a character boundary."""
return str(value).encode()[:LABEL_VALUE_MAX_BYTES].decode(errors="ignore")


def sandbox_labels(attribution: Mapping[str, Any], ports: Iterable[int], create_token: str) -> dict[str, str]:
"""Labels for reconnect (ports), cleanup (the create token, the managed marker) and attribution (cost joins). boat
keeps at most ``MAX_LABELS``; attribution past that is logged and left off."""
labels = {MANAGED_LABEL: "true", PORTS_LABEL: ",".join(str(port) for port in ports), CREATE_LABEL: create_token}
for key, value in attribution.items():
slug = _UNSAFE_KEY.sub("-", str(key)).strip("-./")
label = f"{ATTRIBUTION_LABEL_PREFIX}{slug}"[:LABEL_KEY_MAX]
if value is None or not slug:
continue
if len(labels) == MAX_LABELS:
logger.warning("Ignoring attribution %r on a boat sandbox: boat keeps at most %s labels", key, MAX_LABELS)
continue
labels[label] = _label_value(value)
return labels


def sandbox_name(attribution: Mapping[str, Any]) -> str:
"""``ae-<random>`` plus the attribution values in key order: unique, findable by people."""
slugs = [
_UNSAFE_NAME.sub("-", str(attribution[key])).strip("-")
for key in sorted(attribution)
if attribution[key] is not None
]
return "-".join(["ae", uuid.uuid4().hex[:8], *filter(None, slugs)])[:_NAME_MAX].rstrip("-")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Name length is hardcoded

sandbox_name hardcodes the random suffix length in uuid.uuid4().hex[:8]. The repository requires magic numbers to live in descriptive class or instance variables. Give this length a named setting so readers can understand and change it without editing the expression. This repository requirement must be satisfied before merging.

Rule Used: Store magic numbers as class or instance variables with descriptive names rather than using them inline in the code. (source)

Learned From
scaleapi/scaleapi#126388

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agent_env/providers/sandbox_providers/boat/config.py
Line: 97

Comment:
**Name length is hardcoded**

`sandbox_name` hardcodes the random suffix length in `uuid.uuid4().hex[:8]`. The repository requires magic numbers to live in descriptive class or instance variables. Give this length a named setting so readers can understand and change it without editing the expression. This repository requirement must be satisfied before merging.

**Rule Used:** Store magic numbers as class or instance variables with descriptive names rather than using them inline in the code. ([source](https://app.greptile.com/scale-ai/-/custom-context?memory=002e0051-41ad-46c1-9098-47433c580150))

**Learned From**
[scaleapi/scaleapi#126388](https://github.com/scaleapi/scaleapi/pull/126388)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Cursor Fix in Claude Code Fix in Codex



def ports_from_labels(labels: Mapping[str, str] | None) -> list[int]:
"""The exposed ports a sandbox's labels record."""
return [int(port) for port in str((labels or {}).get(PORTS_LABEL, "")).split(",") if port.strip().isdigit()]
Loading
Loading