Skip to content
AgentEnv Framework

Environment Plugins

Add an env type from an installed package, using OpenCiv3's client on a GPU VM as the example

Why a New Env Type

agent-env openciv3 setup registers the game as a stock MCPServerEnv, which runs a Docker image as a container, behind a gateway or on its own, as Choose the environment's topology shows. Drawing every turn live, not on the CPU afterwards, needs a GPU VM image, a screen size and a view. The plugin doesn't ship that yet; this page writes openciv3_client, which stores those fields and leaves the deploy to an environment provider.

An env type subclasses Env, names itself with a type ClassVar, and turns its stored document back into an object with from_dict:

src/agentenv_openciv3/client_env.py
from __future__ import annotations

from typing import Any, ClassVar, Optional, Self

from agent_env.artifact.artifacts.vm_image import VMImageArtifact
from agent_env.env.env import DeployedEnv, Env
from agent_env.env.store import register_env_instance
from agent_env.providers.env_providers.env_provider import EnvironmentProvider, build_env_provider
from agent_env.providers.sandbox_providers.sandbox_provider import build_sandbox_provider


class OpenCiv3ClientEnv(Env):
    type: ClassVar[str] = "openciv3_client"
    description: ClassVar[str] = "OpenCiv3 on a GPU VM, the real client drawing every turn"
    env_provider_types: ClassVar[tuple[str, ...]] = ("openciv3_client_vm",)

    def __init__(self, id: str, version: Optional[int], vm_image: VMImageArtifact, environment_name: str, *,
                 screen_width: int = 1920, screen_height: int = 1080, view: str = "spectator",
                 env_provider_type: str = "openciv3_client_vm", metadata: Optional[dict[str, Any]] = None):
        super().__init__(id, version, metadata=metadata)
        if not isinstance(vm_image, VMImageArtifact):
            raise TypeError(f"vm_image must be a VMImageArtifact, not a {type(vm_image).__name__}")
        if not environment_name:
            raise ValueError("environment_name cannot be empty")
        if screen_width <= 0 or screen_height <= 0:
            raise ValueError(f"screen size must be positive, got {screen_width}x{screen_height}")
        if view not in ("spectator", "agent"):
            raise ValueError(f"view must be 'spectator' or 'agent', got {view!r}")
        self.vm_image = vm_image
        self.environment_name = environment_name
        self.screen_width = screen_width
        self.screen_height = screen_height
        self.view = view
        self.env_provider_type = env_provider_type
        self._provider: Optional[EnvironmentProvider] = None
        self._deployed: Optional[DeployedEnv] = None

    def to_dict(self) -> dict[str, Any]:
        return {
            **super().to_dict(),
            "vm_image": {"id": self.vm_image.id, "version": self.vm_image.version, "type": self.vm_image.type},
            "environment_name": self.environment_name,
            "screen_width": self.screen_width,
            "screen_height": self.screen_height,
            "view": self.view,
            "env_provider_type": self.env_provider_type,
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> Self:
        image = data["vm_image"]
        return cls(
            id=data["id"],
            version=data.get("version"),
            vm_image=VMImageArtifact.get(image["id"], version=image["version"]),
            environment_name=data["environment_name"],
            screen_width=data["screen_width"],
            screen_height=data["screen_height"],
            view=data["view"],
            env_provider_type=data["env_provider_type"],
            metadata=data.get("metadata"),
        )

type is the identity written into every stored document, and the registry reads each document back through it. The inherited to_dict supplies id, type, version and metadata, and the document keeps the VM image as a reference to one version, which from_dict loads back.

The checks run in __init__, which put calls before it writes, so a bad env is never stored: view="god" fails with ValueError: view must be 'spectator' or 'agent', got 'god'.

Its deploy hands the work to the openciv3_client_vm provider, which Environment Provider Plugins writes.

Declare the Entry Point

The package registers the class under the agent_env.envs entry-point group, and the entry-point name must equal the class's type:

pyproject.toml
[project]
name = "agentenv-openciv3"
version = "0.1.0"
dependencies = ["agentenv-framework>=0.9.1260", "agentenv-framework-protocol>=0.1.275,<0.2", "mcp>=1.25,<2"]

[project.entry-points."agent_env.envs"]
openciv3_client = "agentenv_openciv3.client_env:OpenCiv3ClientEnv"

The same file declares the package's other contributions, which Plugins lists. A class that leaves from_dict unimplemented is reported as failed with invalid-plugin, and a plugin cannot replace a built-in type.

Install and Check It

A plugin must be installed into the same environment as agent-env. agent-env plugin add runs that environment's own installer and then checks the new package; installing it with that installer directly, as uv tool install agentenv-framework --with-editable ./agentenv-openciv-plugin, works too:

Terminal
agent-env plugin add ./agentenv-openciv-plugin
agent-env plugin list
agent-env plugin check
Output (excerpt)
PACKAGE             VERSION   PROVIDES                                                                                  STATUS
agentenv-framework  0.9.1267  bundle hello                                                                              ok
agentenv-openciv3   0.1.0     1 env, 3 task steps, 1 sandbox provider, 1 environment provider, 1 CLI command, 1 bundle  ok

ok: 2 plugin package(s), 9 contribution(s), all in effect

Put and Get It

The CLI has no put for a custom env type, so you register the VM image and the env from Python:

put_client.py
from agent_env.artifact.artifacts.vm_image import VMImageArtifact
from agent_env.env.env import Env
from agentenv_openciv3.client_env import OpenCiv3ClientEnv

image = VMImageArtifact.put(
    id="openciv3-client-gpu", description="The OpenCiv3 env and client on a GPU desktop",
    image_name="registry.test/openciv3/client-gpu:1", disk_size_gb=40, cpu=8, memory_mb=16384, sandbox_type="gpu_vm",
)
env = OpenCiv3ClientEnv.put(
    id="openciv3-live", vm_image=image, environment_name="openciv3",
    screen_width=1920, screen_height=1080, view="spectator",
)
loaded = Env.get("openciv3-live")
print("Env.get ->", type(loaded).__name__, "round-trips:", loaded.to_dict() == env.to_dict())
print("query().type('openciv3_client') ->", [(e.id, e.version) for e in Env.query().type("openciv3_client").execute()])
Output
Env.get -> OpenCiv3ClientEnv round-trips: True
query().type('openciv3_client') -> [('openciv3-live', 1)]

Env.get returns an OpenCiv3ClientEnv because the registry maps the stored type to the plugin's class, and from_dict loads version 1 of the VM image the document pins.

Use It in a Task

A deploy_env step deploys the env by its id, and later steps find the instance by the same id. The openciv3_match and save_env_recording steps come from Task Step Plugins:

tasks/live-match.json
[
  {"id": "deploy", "type": "deploy_env", "env_id": "openciv3-live"},
  {"id": "agent", "type": "deploy_agent", "agent_name": "opus", "a2a_agent_id": "openciv3-claude",
   "env_ids": ["openciv3-live"], "depends_on": ["deploy"]},
  {"id": "match", "type": "openciv3_match", "env_id": "openciv3-live", "turns": 10, "civs": {"opus": "Rome"},
   "depends_on": ["agent"]},
  {"id": "play", "type": "prompt_agent", "agent_name": "opus", "prompt": "Play Rome.",
   "depends_on": ["match"]},
  {"id": "recording", "type": "save_env_recording", "env_id": "openciv3-live", "depends_on": ["play"]},
  {"id": "teardown", "type": "teardown_sandboxes", "env_ids": ["openciv3-live"], "depends_on": ["recording"]}
]

deploy_env passes its fields to deploy, and sandbox_type only when the step or --env-sandbox sets one, so this run uses the image's gpu_vm. teardown_sandboxes terminates the VM that the instance's record names:

Terminal
agent-env task create --id openciv3-live-match --project-id demo tasks/live-match.json
agent-env task run --id openciv3-live-match --project-id demo --output-dir out
Output (task run, excerpt)
Task instance: openciv3-live-match-dx6mk15o
Completed step [1/6]: deploy_env (id=deploy) [4.4s]
deployed-env:
  instance_id: openciv3-live-mkjg2vgq
  env_id: openciv3-live
  env_version: 1
  mcp_url: http://127.0.0.1:51557/mcp
  sandbox_id: vm-11aae0b9
  sandbox_type: gpu_vm
Completed step [4/6]: prompt_agent (id=play) [94.3s]
Completed step [5/6]: save_env_recording (id=recording) [4.1s]
Completed step [6/6]: teardown_sandboxes (id=teardown) [0.2s]
Task completed!

agent-env env deploy --id openciv3-live deploys it on its own too. It prints Sandbox backend: config default even though deploy picked gpu_vm, and it never closes the instance.

Last updated on

Ask a question · Report an issue

On this page