Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
1830b54
feat(artifact): give every artifact metadata, changed by compare-and-…
earakely-scale Oct 27, 2026
fa41567
feat(computer): add the computer artifact type
earakely-scale Oct 27, 2026
9bba513
feat(bundle): let a bundle give its artifacts aliases
earakely-scale Oct 27, 2026
6eb4268
feat(computer): ship the ubuntu-24.04 base in a computers bundle
earakely-scale Oct 27, 2026
c8cec4d
test(computer): boot the ubuntu-24.04 base and register it from the l…
earakely-scale Oct 27, 2026
77bd3e3
feat(cli): put and get computer artifacts
earakely-scale Oct 27, 2026
011e6ab
fix(computer): keep the base's desktop restarting however often X dies
earakely-scale Oct 27, 2026
b7d1c6d
fix(protocol): keep the Fixture Desk over the whole screen under a wi…
earakely-scale Oct 27, 2026
f549dd1
test(computer): lay the scaffold's driver and desk on the ubuntu-24.0…
earakely-scale Oct 27, 2026
04628c5
fix(computer): refuse a computer whose image isn't a stored image art…
earakely-scale Oct 27, 2026
c37df7a
fix(computer): refuse a resolution outside 1 to 32767 pixels a side a…
earakely-scale Oct 27, 2026
ef7ac0a
fix(computer): read an image recorded by its tag alone at the digest …
earakely-scale Oct 27, 2026
6c55751
test(computer): a released install writes the shipped base over its p…
earakely-scale Oct 27, 2026
6b37a12
fix(artifact): keep an environment universe's legacy metadata key out…
earakely-scale Oct 27, 2026
dd93e6c
refactor(bundle): key the planner's alias tables and uses by the kind…
earakely-scale Oct 27, 2026
66fd559
refactor(computer): drop the artifact computer CLI for now
earakely-scale Oct 27, 2026
5d696dc
refactor(computer): read what an image holds in store/image_store, an…
earakely-scale Oct 27, 2026
09d9e86
refactor(store): read a registry through one request path
earakely-scale Oct 27, 2026
40fe2a7
test(computer): share the docker helper, refuse a scaffold without it…
earakely-scale Oct 27, 2026
68dbe79
fix(store): report a blob that can't be written during inspection as …
earakely-scale Oct 27, 2026
3e2870d
test(installer): count the computers bundle among the seven agent-env…
earakely-scale Oct 27, 2026
18a1aab
fix(computer): write an image whose zstd layer this Python can't read…
earakely-scale Oct 27, 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
1 change: 1 addition & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ updates:
- "/src/agent_env/agents/agents/pi"
- "/src/agent_env/agents/agents/proxy"
- "/src/agent_env/examples/mail-mcp/envs/mail"
- "/src/agent_env/computers/artifacts/ubuntu-24.04"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
Expand Down
10 changes: 8 additions & 2 deletions .github/scripts/infra_build.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"agentenv-pi": "src/agent_env/agents/agents/pi/Dockerfile",
"agentenv-proxy": "src/agent_env/agents/agents/proxy/Dockerfile",
"agentenv-mail-mcp": "src/agent_env/examples/mail-mcp/envs/mail/Dockerfile",
"agentenv-ubuntu-24.04": "src/agent_env/computers/artifacts/ubuntu-24.04/Dockerfile",
}
_TAG = re.compile(r"^src-[0-9a-f]{64}$")
_DIGEST = re.compile(r"^sha256:[0-9a-f]{64}$")
Expand All @@ -57,8 +58,8 @@

# Each image's check, beyond its build: the gateway imports its server, Postgres turns healthy, the sidecars and the
# browser answer their own commands, Claude Code's, Codex's, Gemini's, OpenCode's and pi's agents run their CLIs and
# import their servers, OpenHands' imports its server, which loads the OpenHands SDK, the proxy imports its server, and
# the mail example's env imports its server.
# import their servers, OpenHands' imports its server, which loads the OpenHands SDK, the proxy imports its server, the
# mail example's env imports its server, and the base computer's desktop comes up, every program running.
_SMOKES = {
"agentenv-gateway": ["docker", "run", "--rm", "--entrypoint", "python", "{image}", "-c", "import gateway.entrypoint"],
"agentenv-db-web": ["docker", "run", "--rm", "--entrypoint", "/usr/bin/pgweb", "{image}", "--version"],
Expand All @@ -79,6 +80,11 @@
'pi --version && python3 -c "import a2a_server"'],
"agentenv-proxy": ["docker", "run", "--rm", "--entrypoint", "sh", "{image}", "-c", 'python -c "import a2a_server"'],
"agentenv-mail-mcp": ["docker", "run", "--rm", "--entrypoint", "python", "{image}", "-c", "import server"],
"agentenv-ubuntu-24.04": ["docker", "run", "--rm", "--entrypoint", "sh", "{image}", "-c",
"start-desktop init & for _ in $(seq 60); do sleep 1;"
" s=$(supervisorctl -c /etc/supervisor/supervisord.conf status);"
" echo \"$s\" | grep -q ' RUNNING ' && ! echo \"$s\" | grep -qv ' RUNNING ' && exit 0;"
" done; supervisorctl -c /etc/supervisor/supervisord.conf status; exit 1"],
}


Expand Down
13 changes: 9 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,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`. |
| `artifact/` | Immutable versioned artifacts; every `put()` creates a new version. The document goes to the document store, binary payloads to the object store. Built-ins: `CliArtifact` (`cli`), `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`. |
| `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/`. |
| `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. |
Expand All @@ -120,7 +120,8 @@ and deserialized through a registry.
| `explorer/` | Optional local web UI: `agent-env up`, binds loopback `:8234`. |
| `cli/` | Click CLI with the groups `a2a-agent`, `artifact`, `bundle`, `config`, `env`, `eval`, `plugin`, `run`, `task`, `up`. `task run`, `task run-batch` and `eval run` hand their runs to the configured `[runner]` and follow them. |
| `agents/` | The `agents` bundle: the agents agent-env ships, one folder each under `agents/agents/`, with a smoke task each under `agents/tasks/`. Its Claude Code, `@local/agentenv-framework/agents/claude-code`, is the default agent. An agent's Python packages are its folder's `requirements.txt`, installed in its image, never in agent-env's own environment. A run that pulls one of its agents reads the whole bundle, so `tst/unit/bundle/shipped_bundles_test.py` parses and checks every shipped bundle. |
| `drivers/` | The `drivers` bundle: the computer drivers agent-env ships as `computer_driver` envs, the reference driver (X11 on Linux, recording through `x11grab`) aliased `reference`. It has no tasks: a bundle with neither tasks nor evals is refused unless its `aliases.toml` names something for other bundles to use. `tst/integration/computer/` serves it on a scaffold desktop image and runs the conformance probe and an agent against it. |
| `computers/` | The `computers` bundle: the base computers agent-env ships as `computer` artifacts, `ubuntu-24.04` (XFCE on Xvfb, supervisord as the session user, settings as `AGENTENV_SETTING_<NAME>`), aliased through `[artifacts]`. It has no tasks either. |
| `drivers/` | The `drivers` bundle: the computer drivers agent-env ships as `computer_driver` envs, the reference driver (X11 on Linux, recording through `x11grab`) aliased `reference`. It has no tasks: a bundle with neither tasks nor evals is refused unless its `aliases.toml` names something for other bundles to use. `tst/integration/computer/` serves it on the `ubuntu-24.04` base in a scaffold image and runs the conformance probe and an agent against it. |
| `examples/` | The example bundles agent-env ships (`hello`, and `echo-mcp`, which needs Docker). Tests run them, but they are examples for users, not fixtures. Every shipped bundle is registered under `agent_env.bundles` in `pyproject.toml`, `<name> = "<package>"` shipping `src/<package as a path>/<name>/`. |

## Extension points
Expand Down Expand Up @@ -167,6 +168,10 @@ Out-of-tree code plugs in either through `config.toml`, as a `module:Class` poin
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
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
context-only and built in the VM, so a service like the hub's worker needs no Docker.
Expand Down Expand Up @@ -304,8 +309,8 @@ and every job that reads those folders runs it first, so an image's tag, its bui
and creates the GitHub release, whose notes are the titles of the pull
requests merged since the previous release.
Once the repository variable `PUBLISH_INFRA_IMAGES` is `true`, it also publishes the infra images agent-env
ships and the shipped bundles', one per agent folder and one for the env `a2a-agent validate` deploys (the `mail-mcp`
example's; `agent_env.env.published_images`), whose `src-<hex>` tags GHCR lacks: after `main`'s CI passes, each is
ships and the shipped bundles', one per agent folder, one for the env `a2a-agent validate` deploys (the `mail-mcp`
example's) and one per shipped base computer (`agentenv-ubuntu-24.04`; `agent_env.env.published_images`), whose `src-<hex>` tags GHCR lacks: after `main`'s CI passes, each is
built on a runner of each platform, checked, and tagged as one multi-platform image, and a tag that exists is
never moved. The infra images are published before the protocol, and a failed infra image job sends neither package
to PyPI. An agent's `requirements.txt`, and that env's, keeps its protocol floor, unpinned, so its image is built again
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ def main(state_dir: Path) -> None:
root.title("Fixture Desk")
width, height = root.winfo_screenwidth(), root.winfo_screenheight()
root.geometry(f"{width}x{height}+0+0")
root.attributes("-fullscreen", True)
root.configure(bg="#f0f0f0")
state: dict = {"name": "", "notes": "", "keys": [], "saved_count": 0, "subscribed": False, "selected": None,
"scroll_top": 0.0, "cells": {}, "right_clicks": 0, "double_clicks": 0, "drags": [],
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ agent-env = "agent_env.cli:cli"

[project.entry-points."agent_env.bundles"]
agents = "agent_env"
computers = "agent_env"
drivers = "agent_env"
hello = "agent_env.examples"
echo-mcp = "agent_env.examples"
Expand Down
2 changes: 2 additions & 0 deletions src/agent_env/artifact/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from agent_env.artifact.artifact import Artifact
from agent_env.artifact.ref import ArtifactRef
from agent_env.artifact.artifacts.cli import CliArtifact
from agent_env.artifact.artifacts.computer import ComputerArtifact
from agent_env.artifact.artifacts.docker_image import DockerImageArtifact
from agent_env.artifact.artifacts.file import FileArtifact
from agent_env.artifact.artifacts.file_artifact_universe import FileArtifactUniverse
Expand All @@ -23,6 +24,7 @@
"Artifact",
"ArtifactRef",
"CliArtifact",
"ComputerArtifact",
"DockerImageArtifact",
"FileArtifact",
"FileArtifactUniverse",
Expand Down
21 changes: 21 additions & 0 deletions src/agent_env/artifact/artifact.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ class Artifact(BaseModel):
id: str = Field(description="Unique artifact identifier")
version: int = Field(default=0, description="Artifact version: 0 takes the next free one on put; another must be the next")
type: str = Field(description="The type of Artifact")
metadata: dict[str, Any] = Field(default_factory=dict, description=(
"What is learned about this version after it's written, such as a computer's inspected images; outside the "
"versioned fields, and changed only by compare-and-swap (merge_metadata)"))

toml_refs: ClassVar[tuple[EntityRef, ...]] = ()
toml_keys: ClassVar[dict[str, type]] = {} # what an artifact.toml may set, for a type without an accept_toml
Expand Down Expand Up @@ -76,6 +79,24 @@ def put(cls, **kwargs: Any) -> Self:
instance = cls(**kwargs)
return get_artifact_store().put_document(instance)

def merge_metadata(self, updates: dict[str, Any], retries: int = 3) -> None:
"""Merge ``updates`` into this version's stored metadata, re-reading and trying again when another writer
changed it meanwhile."""
from agent_env.artifact.store import get_artifact_store
from agent_env.store.base import ConcurrentModificationError

if self.version < 1:
raise ValueError(f"artifact {self.id!r} isn't stored yet, so it has no metadata to update")
store, metadata = get_artifact_store(), self.metadata
for attempt in range(retries + 1):
try:
self.metadata = store.update_metadata(self.id, self.version, metadata, {**metadata, **updates})
return
except ConcurrentModificationError:
if attempt == retries:
raise
metadata = store.get(self.id, self.version).metadata

@classmethod
def from_toml(cls, data: dict[str, Any], ctx: AuthoringContext) -> Self:
"""Write the artifact authored as ``data`` (its toml, with the keys ``toml_refs`` declares
Expand Down
Loading
Loading