Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
fc3ccc4
feat(bundle): resolve installed aliases in every entry's references, …
earakely-scale Oct 27, 2026
65afedf
feat(protocol): serve a computer driver from its file with agentenv_p…
earakely-scale Oct 27, 2026
1431cf0
feat(computer): a driver declares how a computer installs it and the …
earakely-scale Oct 27, 2026
6caa7d9
feat(computer): the computer env type, composed at put into an image …
earakely-scale Oct 27, 2026
e96e7e1
test(computer): compose the shipped base and reference driver and pro…
earakely-scale Oct 27, 2026
e5502e7
docs(computer): the computer env type, the driver's install and alias…
earakely-scale Oct 27, 2026
8d5d215
fix(computer): keep when a pair started building in its finished record
earakely-scale Oct 27, 2026
2eafdd7
fix(computer): reuse before building the base, sign in to its registr…
earakely-scale Oct 27, 2026
68508df
fix(computer): say a build that can't run the other arch's programs h…
earakely-scale Oct 27, 2026
dfb451e
fix(computer): push an env's composed images to its own image store, …
earakely-scale Oct 27, 2026
5387358
fix(computer): compose under the env's stored id, and merge each pair…
earakely-scale Oct 27, 2026
9ff01d3
test(computer): record the composed computer test's CI time for the s…
earakely-scale Oct 28, 2026
8001937
fix(computer): refuse a tools.hide pattern that hides one of the seve…
earakely-scale Oct 28, 2026
dd672f9
refactor(computer): a computer env holds its computer and driver, not…
earakely-scale Oct 28, 2026
17ea4ca
refactor(computer): a computer env names no data paths
earakely-scale Oct 28, 2026
72bf0b7
refactor(computer): a computer env builds every form its base has, na…
earakely-scale Oct 28, 2026
3ee56e4
fix(bundle): an entity-only plan resolves installed aliases too
earakely-scale Oct 28, 2026
84341cd
refactor(computer): computer_settings, the base's settings by name, n…
earakely-scale Oct 28, 2026
4442bb9
refactor(computer): a computer env hides no tools of its own
earakely-scale Oct 28, 2026
694532b
refactor(bundle): an env's write says it builds only when it does, no…
earakely-scale Oct 28, 2026
3ef998f
fix(bundle): an entity-only plan leaves the bundle's tasks and evals …
earakely-scale Oct 28, 2026
572960c
fix(computer): an env's display reaches the image of a base that read…
earakely-scale Oct 28, 2026
98e139a
fix(computer): refuse a driver file named outside its folder before w…
earakely-scale Oct 28, 2026
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
13 changes: 7 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ and deserialized through a registry.

| Package | Contents |
|---|---|
| `env/` | `Env` base class: a subclass declares `type`, implements `from_dict` and `async deploy(...) -> DeployedEnv`, and may override `reset` and `load_file_artifact_universe`. Built-ins in `env/envs/`: `MCPServerEnv` (`mcp_server`), `MultiEnv` (`multi`, MCP servers plus websites), `ServiceDBEnv` (`service_db`, PostgreSQL), `WebsiteEnv` (`website`), `GatewayEnv` (`gateway_server`), `ComputerDriverEnv` (`computer_driver`, a computer driver's files and what its class declares for each platform it supports, read at put in a process of its own, `agentenv_protocol.computer._declare`, where the driver's own dependencies needn't be installed; its deploy is refused until agent-env creates computers). `env/gateway/gateway.py` is the env-side gateway server, container-only code. While its clock is armed it serves agents `get_time` (`env/gateway/get_time.py`), which answers in UTC and, given an IANA `time_zone`, also in that zone; executors' working `hours` (`env/gateway/triggers.py`) take a fixed `utc_offset` or an IANA `time_zone`. |
| `env/` | `Env` base class: a subclass declares `type`, implements `from_dict` and `async deploy(...) -> DeployedEnv`, and may override `reset` and `load_file_artifact_universe`. Built-ins in `env/envs/`: `MCPServerEnv` (`mcp_server`), `MultiEnv` (`multi`, MCP servers plus websites), `ServiceDBEnv` (`service_db`, PostgreSQL), `WebsiteEnv` (`website`), `GatewayEnv` (`gateway_server`), `ComputerDriverEnv` (`computer_driver`, a computer driver's files and what its class declares for each platform it supports, read at put in a process of its own, `agentenv_protocol.computer._declare`, where the driver's own dependencies needn't be installed, with the `port` it serves on and its install by OS, `[install.<os>]`: files, a `python` requirements file for a venv, and the services the base's init runs; its deploy is refused until agent-env creates computers), `ComputerEnv` (`computer`, in `env/envs/computer/`: a base computer and a driver, matched at put by gate 1 before anything is stored, then composed by `composer.py` into an image for each form and arch with this machine's Docker, the other arch through its emulation, tagged `agentenv-computers:<key>` in the image store by a key of the image's Dockerfile and build context so any env with the same inputs reuses it; each pair's status goes into its metadata, `composed`, the arches built are written as its computer, a `computer` artifact over one index, and `agent-env env build` resumes what a put left; builds end `unverified`, and its deploy is refused until a computer can be booted). `env/gateway/gateway.py` is the env-side gateway server, container-only code. While its clock is armed it serves agents `get_time` (`env/gateway/get_time.py`), which answers in UTC and, given an IANA `time_zone`, also in that zone; executors' working `hours` (`env/gateway/triggers.py`) take a fixed `utc_offset` or an IANA `time_zone`. |
| `artifact/` | Immutable versioned artifacts; every `put()` creates a new version. The document goes to the document store, binary payloads to the object store. Every artifact but an environment universe, whose `metadata` key holds its legacy metadata file ids, carries a `metadata` dict outside its versioned fields, changed by compare-and-swap (`merge_metadata`). Built-ins: `CliArtifact` (`cli`), `ComputerArtifact` (`computer`, a base computer: its architectures, OS, session user, settings and container form, whose image `put` reads from its registry into `metadata.validated_images`), `FileArtifact` (`file`), `FileArtifactUniverse` (`file_artifact_universe`), `DockerImageArtifact` (`docker_image`), `EnvironmentArtifact` (`environment`), `EnvironmentUniverseArtifact` (`environment_universe`), `SkillArtifact` (`skill`); `[artifacts] type_aliases` maps another stored spelling of a type onto one of them. A `DockerImageArtifact` holds a tar.gz of its image, or, written by `put_ref` (`--image-ref` on `env mcp-server put` and `a2a-agent put`), only a registry reference with its tag pinned to a digest, which sandboxes pull, or a published image's tag with its `image_digest` and its build context, which sandboxes pull by the digest and build from the context when they can't (the infra images, and a bundle agent built from a shipped agent's sources, `Plan.published`), or, written by `put_context`, only its build context, which a VM sandbox builds before running it (`VmSandbox.load_docker_images`) and Modal builds before creating its container (`ModalSandboxProvider.prepare_image`, outside a fallback chain's deadline); anything else that runs images by name (a container on the local provider, a lone server on any provider but Modal) refuses such an image. A build context (`utils/docker/context.py`) is what `docker build` would send, `.dockerignore` applied, written so equal files give equal bytes and an equal `source_digest`. |
| `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/`. |
Expand Down Expand Up @@ -153,7 +153,7 @@ Out-of-tree code plugs in either through `config.toml`, as a `module:Class` poin
- A bundle can give its agents aliases in an `aliases.toml` at its root (`[agents] <folder> = ["<alias>", ...]`, read
by `plugins/_bundles.py`): the shipped `agents` bundle names Claude Code `claude-code`. A reference to an alias
names the agent its bundle gives it unless the run's bundle has an entity of that name or the store an agent of that
id, which wins. The planner pulls the agent in and writes the task naming it by its id (`Plan.aliases`); a step run
id, which wins. The planner pulls the agent in and writes the task naming it by its id (`Plan.alias_uses`, by kind); a step run
outside a bundle run gets it from `A2AAgent.get`, which reads the store first and an alias after (the planner reads
the store itself, as `STORE_GETTERS` does). agent-env's own bundles' aliases are theirs, and an alias two other
bundles give is refused, naming both. `agent-env a2a-agent list` lists the installed agents with their aliases, then
Expand All @@ -162,15 +162,16 @@ Out-of-tree code plugs in either through `config.toml`, as a `module:Class` poin
simulator: `plan.solver_steps`), pulled in and checked as a task's reference to it would be, through per-step
`step_params` overrides, so the tasks stay as written.
- `aliases.toml` gives envs aliases the same way (`[envs] <folder> = ["<alias>", ...]`; the `mail-mcp` example names
its env `mail`), resolved by the planner (`Plan.env_aliases`) and by `Env.get`, only in a task's steps: an env
definition (a MultiEnv's servers) naming one is refused. The env `Env.get` gives for an alias answers to the alias's
its env `mail`), resolved by the planner and by `Env.get`. The planner resolves an alias in any entry's references as
in a task's steps, so a MultiEnv's servers or a computer env's driver may name one, and writes the entry naming the
env's id, the entry pulled in from another bundle as well as the bundle's own. The env `Env.get` gives for an alias answers to the alias's
id, so a deployment records, and steps look it up by, the id the task names; its metadata writes go to the env the
alias names (`Env.stored_id`). An alias names its env only where namespace routing is on. An env store refuses to
create an env under an installed env alias's id (`EnvStore.put_document`), so a deployment recorded under an alias
always reloads the alias's env; an env stored under such an id before keeps taking versions.
- `aliases.toml` gives artifacts aliases too (`[artifacts] "<folder>" = ["<alias>", ...]`; the `computers` bundle
names its base `ubuntu-24.04`), resolved by the planner (`Plan.artifact_aliases`) in a task's steps alone, as env
aliases are. `Artifact.get` reads no alias: the planner rewrites every alias a task names to its id. An alias may
names its base `ubuntu-24.04`), resolved by the planner wherever env aliases are, as a computer env's `computer` names
its base. `Artifact.get` reads no alias: the planner rewrites every alias an entry or a task names to its id. An alias may
hold '.', so quote the folder in the toml.
`A2AAgent.validate()` deploys `mail` (`A2AAgent.VALIDATION_ENV_ID`) with namespace routing on, first writing the
example (its writes and infra, no run) when the store has no env of that id: on a remote sandbox its image is
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""Serve a computer driver from its file, as a composed computer's program starts it:
``python -m agentenv_protocol.computer.serve <file>:<Class>``, run in the driver's folder, serves the class on
``MCP_PORT``. The file's folder is importable, so it may import its own modules."""
from __future__ import annotations

import importlib.util
import sys
from pathlib import Path

from .driver import ComputerDriverEnvironment

USAGE = "usage: python -m agentenv_protocol.computer.serve <file>:<Class>"


def main(argv: list[str]) -> None:
if len(argv) != 1 or ":" not in argv[0]:
raise SystemExit(USAGE)
file, _, name = argv[0].partition(":")
driver_class(Path(file), name)().serve()


def driver_class(file: Path, name: str) -> type[ComputerDriverEnvironment]:
"""The class ``name`` in ``file``, imported as a module of its own; exits saying why when it isn't a driver."""
if not file.is_file():
raise SystemExit(f"{file} isn't a file")
sys.path.insert(0, str(file.resolve().parent))
spec = importlib.util.spec_from_file_location(file.stem, file)
module = importlib.util.module_from_spec(spec)
sys.modules[file.stem] = module
spec.loader.exec_module(module)
cls = getattr(module, name, None)
if not (isinstance(cls, type) and issubclass(cls, ComputerDriverEnvironment)):
raise SystemExit(f"{file} has no ComputerDriverEnvironment subclass named {name}")
return cls


if __name__ == "__main__":
main(sys.argv[1:])
75 changes: 75 additions & 0 deletions packages/agentenv-protocol/tests/test_computer_serve.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
"""Serving a driver from its file: the class is imported with its folder importable and served; anything that isn't
a driver class exits saying why."""
from __future__ import annotations

import sys

import pytest

pytest.importorskip("mcp.server.fastmcp")
pytest.importorskip("PIL")

from agentenv_protocol.computer import ComputerDriverEnvironment # noqa: E402
from agentenv_protocol.computer.serve import USAGE, main # noqa: E402

DRIVER = '''from desk_helpers import SIZE
from agentenv_protocol.computer import (ComputerDriverEnvironment, Display, capture, click, computer_driver, display,
drag, press, scroll, type_text)
@computer_driver(name="desk", supports=[{"os": {"type": "linux", "display": ["x11"]}, "arch": ["amd64"]}])
class Desk(ComputerDriverEnvironment):
@display
def size(self): return Display(*SIZE)
@capture
def grab(self): ...
@click
def click(self, x, y, button, count): ...
@drag
def drag(self, path, button): ...
@scroll
def scroll(self, x, y, dx, dy): ...
@type_text
def type(self, text): ...
@press
def press(self, keys): ...
'''


@pytest.fixture
def served(monkeypatch):
calls = []
monkeypatch.setattr(ComputerDriverEnvironment, "serve", lambda self: calls.append(type(self).__name__))
monkeypatch.setattr(sys, "path", list(sys.path))
monkeypatch.setattr(sys, "modules", dict(sys.modules))
return calls


def test_the_class_is_imported_from_its_file_with_its_folder_importable_and_served(tmp_path, served):
(tmp_path / "driver.py").write_text(DRIVER)
(tmp_path / "desk_helpers.py").write_text("SIZE = (10, 10)\n")

main([f"{tmp_path / 'driver.py'}:Desk"])

assert served == ["Desk"]


@pytest.mark.parametrize("argv, message", [
([], USAGE), (["driver.py"], USAGE), (["missing.py:Desk"], "missing.py isn't a file"),
(["driver.py:Helper"], "has no ComputerDriverEnvironment subclass named Helper"),
], ids=["nothing", "no-class", "no-file", "not-a-driver"])
def test_what_isnt_a_driver_class_exits_saying_why(tmp_path, served, monkeypatch, argv, message):
(tmp_path / "driver.py").write_text("class Helper:\n pass\n")
monkeypatch.chdir(tmp_path)

with pytest.raises(SystemExit) as stopped:
main(argv)

assert message in str(stopped.value) and served == []
4 changes: 3 additions & 1 deletion src/agent_env/artifact/artifacts/computer.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@

OS_TYPES = ("linux",)
FORMS = ("container",)
#: What a setting holds: its default's type, which a value an env or a deploy gives must share.
SettingValue = str | int | float | bool


class LinuxOS(BaseModel):
Expand Down Expand Up @@ -53,7 +55,7 @@ class Setting(BaseModel):

model_config = ConfigDict(extra="forbid")

default: str | int | float | bool
default: SettingValue
phase: Literal["build", "deploy"]
scope: Literal["task", "user"] = "task"
secret: bool = False
Expand Down
27 changes: 15 additions & 12 deletions src/agent_env/artifact/artifacts/docker_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ def put(
repository = image_repository(id)
image_ref = image_store.image_ref(repository, f"v{version}")
image_store.ensure_repository(repository)
_push_local_image(image_name, image_ref, image_store)
push_local_image(image_name, image_ref, image_store)
platform = normalize_platform(platform) if platform else _image_platform(image_ref)

with tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False) as tmp:
Expand Down Expand Up @@ -584,20 +584,13 @@ def push_context_image(id: str, context_path: str, dockerfile_path: str,
before()
local = f"{local_image_repository(id)}:{tag}"
build_image(Path(context_path) / context.dockerfile, Path(context_path), local, platform=DEFAULT_BUILD_PLATFORM)
_push_local_image(local, ref, store)
push_local_image(local, ref, store)
return ref, pin_digest(ref, store.auth(ref)).rpartition("@")[2]


def _push_local_image(local_tag: str, image_ref: str, image_store) -> None:
auth = image_store.auth(image_ref)
if auth is not None:
login = subprocess.run(
["docker", "login", "--username", auth.username, "--password-stdin", auth.registry],
input=auth.password, text=True, capture_output=True,
)
if login.returncode != 0:
raise RuntimeError(f"docker login failed: {login.stderr}")

def push_local_image(local_tag: str, image_ref: str, image_store) -> None:
"""Push this machine's ``local_tag`` to ``image_ref`` in ``image_store``, signing in with its credentials first."""
docker_login(image_store.auth(image_ref))
for cmd, label in [
(["docker", "tag", local_tag, image_ref], "tag"),
(["docker", "push", image_ref], "push"),
Expand All @@ -608,6 +601,16 @@ def _push_local_image(local_tag: str, image_ref: str, image_store) -> None:
logger.info(f"Pushed {local_tag} -> {image_ref}")


def docker_login(auth) -> None:
"""Sign this machine's Docker in to ``auth``'s registry, when there are credentials to sign in with."""
if auth is None:
return
login = subprocess.run(["docker", "login", "--username", auth.username, "--password-stdin", auth.registry],
input=auth.password, text=True, capture_output=True)
if login.returncode != 0:
raise RuntimeError(f"docker login failed: {login.stderr}")


def _parse_copy_sources(dockerfile_text: str, dockerfile_rel_path: str | None = None) -> list[str]:
"""Parse COPY/ADD source paths from Dockerfile text. Returns top-level directories to include in context tar."""
paths: list[str] = []
Expand Down
4 changes: 3 additions & 1 deletion src/agent_env/bundle/authoring.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import stat
import tempfile
import unicodedata
from collections.abc import Iterator, Mapping, Sequence
from collections.abc import Callable, Iterator, Mapping, Sequence
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
Expand Down Expand Up @@ -49,6 +49,8 @@ class AuthoringContext:

bundle: Bundle
entry: BundleEntry
#: Called just before writing the entry builds an image with this machine's Docker, which a run says it does.
on_build: Callable[[], None] | None = None

@property
def id(self) -> str:
Expand Down
7 changes: 5 additions & 2 deletions src/agent_env/bundle/materialize.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,8 @@ def _write_entity(plan: Plan, write: Write, here: bool, on_build: Callable[[Writ
if write.id in plan.published:
return _write_published_image(plan, write)
return _write_build_context(plan, write, here, on_build)
if write.kind is BundleKind.ENV:
return _write_env(plan, write, _before(on_build, write))
return _WRITERS[write.kind](plan, write)


Expand Down Expand Up @@ -281,10 +283,11 @@ def _write_agent(plan: Plan, write: Write) -> int:
AuthoringContext(plan.bundle_of(entry), entry)).version


def _write_env(plan: Plan, write: Write) -> int:
def _write_env(plan: Plan, write: Write, on_build: Callable[[], None] | None = None) -> int:
entry = write.source.entry
cls = get_env_registry()[entry.type]
return cls.from_toml(_pinned(plan, write, cls.toml_refs), AuthoringContext(plan.bundle_of(entry), entry)).version
return cls.from_toml(_pinned(plan, write, cls.toml_refs),
AuthoringContext(plan.bundle_of(entry), entry, on_build=on_build)).version


def _pinned(plan: Plan, write: Write, refs: tuple[EntityRef, ...]) -> Any:
Expand Down
Loading
Loading