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
1 change: 1 addition & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ updates:
- "/src/agent_env/agents/agents/pydantic-ai"
- "/src/agent_env/examples/mail-mcp/envs/mail"
- "/src/agent_env/computers/artifacts/ubuntu-24.04"
- "/src/agent_env/computers/artifacts/ubuntu-24.04-headless"
- "/src/agent_env/computers/disk/linux/boot"
- "/src/agent_env/computers/disk/linux/tools"
- "/src/agent_env/computers/disk/linux/launcher"
Expand Down
7 changes: 7 additions & 0 deletions .github/scripts/infra_build.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"agentenv-pydantic-ai": "src/agent_env/agents/agents/pydantic-ai/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",
"agentenv-ubuntu-24.04-headless": "src/agent_env/computers/artifacts/ubuntu-24.04-headless/Dockerfile",
"agentenv-disk-tools": "src/agent_env/computers/disk/linux/tools/Dockerfile",
"agentenv-qemu-launcher": "src/agent_env/computers/disk/linux/launcher/Dockerfile",
"agentenv-ubuntu-24.04-disk": "src/agent_env/computers/disk/linux/boot/Dockerfile",
Expand Down Expand Up @@ -118,6 +119,12 @@
" 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"],
"agentenv-ubuntu-24.04-headless": [
"docker", "run", "--rm", "--entrypoint", "sh", "{image}", "-c",
"start-computer & 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"],
"agentenv-disk-tools": ["docker", "run", "--rm", "--entrypoint", "sh", "{image}", "-c",
"command -v disk image-env mkfs.ext4 mkfs.vfat mcopy sfdisk qemu-img"],
"agentenv-qemu-launcher": ["docker", "run", "--rm", "--entrypoint", "sh", "{image}", "-c",
Expand Down
18 changes: 12 additions & 6 deletions packages/agentenv-protocol/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -505,15 +505,18 @@ driver has them, through the `served_tools`, `served_extensions` and `served_ope
- **The input queue.** Input calls run one at a time in arrival order; one that waits past
`queue_wait_s` is refused `input_busy`. Screenshots and waits never queue.
- **The computer skill.** `computer/v1` declares the seven tools, its params the driver's
features. The driver serves its skill through `skill/v1` (an `@agent_skill` handler on the base
features, among them `screen`: `desktop`, or `page` for a driver whose screen is a website
browser's active tab, which a computer env pairs with exactly one website browser. The driver serves its skill through `skill/v1` (an `@agent_skill` handler on the base
class): a SKILL.md written from the computer as it booted (its platform, session user and home,
the live screen size, what its input takes, its OS's shortcuts) and ending with the driver
class's `notes`, markdown for what its card can't say. A driver whose software ships a skill of
its own returns it from `bundled_skill()` (its description, body and other files), and the
computer skill leads with it, the computer's part following.
- **Framework extensions.** `computer-ready/v1` (`status`: real checks of the display and a
capture, the driver's own `ready_checks()`, and one per app on the computer, asked of the app's
own server (see Computer apps); tools refuse `device_unavailable` until they pass once), `computer-display/v1`, `computer-spaces/v1` (the default space, written into the tool
own server (see Computer apps); tools refuse `device_unavailable` until they pass once; a check
that can't pass again is `final`, and while one fails the status carries `failed`, saying why,
which `boot.ready()` raises at once rather than waiting out its timeout), `computer-display/v1`, `computer-spaces/v1` (the default space, written into the tool
descriptions), `computer-keyboard/v1`, `computer-exec/v1` (`run {argv, cwd?, env?, timeout_s?,
request_id}`: output capped per stream, a repeated `request_id` answered with the first result) and,
with `recording`, `computer-recording/v1` (below).
Expand Down Expand Up @@ -742,7 +745,7 @@ its card lists each one's card as a child environment, rooted at `/svc/<app>/`,
requests to the app's server.

`MailApp.declaration()` returns what the class declares, an `AppDeclaration`, which a `computer_app`
env stores, and `python -m agentenv_protocol.computer._declare <folder> <file> <class>` prints it as
env stores (`browser: true` for a website browser), and `python -m agentenv_protocol.computer._declare <folder> <file> <class>` prints it as
JSON, or a driver's `DriverDeclaration`, without the class's own dependencies installed.

### Website browsers
Expand All @@ -769,8 +772,8 @@ are the profile's template. The base class adds:
`AGENTENV_SITE_PROXY` set, it goes out through that proxy, with WebRTC's non-proxied UDP off.
Alone it bypasses nothing, loopback included; in a computer it bypasses loopback (`127.0.0.1`,
`[::1]`, `localhost`), so the apps beside it stay reachable while `*.localhost` names still go
through. Alone (`AGENTENV_BROWSER_ALONE=1`) it also takes `--window-size` and `--headless=new`,
except on the open web (`AGENTENV_BROWSER_OPEN_WEB=1`), and refuses to start with no proxy.
through. Alone, on a computer with no display (`XDG_SESSION_TYPE=none`, a browser computer's), it
also takes `--window-size` and `--headless=new`, and refuses to start with no proxy.
`declaration()` refuses a subclass whose command carries any of these, or `--remote-allow-origins`, or
any other way to open a debugging port.
- **Before each launch** it empties its cache, so no launch inherits another's, and writes the deploy's
Expand Down Expand Up @@ -803,7 +806,10 @@ are the profile's template. The base class adds:
launch (with its cause: `boot`, `server_restart`, `launch`, `data/add` or `data/reset`), each stop it made
and each exit it didn't, with its exit `code` or the `signal` that ended it, since the computer booted.
Nothing relaunches a browser that exits on its own: in a computer the agent can, and alone it's an infra
failure, its ready check false.
failure, its ready check false. `page/viewport`, `page/capture` and `page/input`, framework-only, are
its active tab's page as a page driver sees it: its size, which headless is its window's; a PNG of it,
or `blank` for a tab that paints nothing within 3 s (1 s with no URL yet); and CDP input calls
(`Input.dispatchMouseEvent`, `Input.dispatchKeyEvent`, `Input.insertText`), sent to it in order.
- **Its skill**: sites by their real names, files to upload in `~/Uploads` and downloads in
`~/Downloads`, folders it makes.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ class AppDeclaration(BaseModel):
"""What a ComputerAppEnvironment class declares, as a ``computer_app`` env stores it: the platforms it ``supports``,
as a driver names them; ``os``, what it declares for each OS type; ``settings`` and ``build_settings``, the JSON
schemas of its ``Settings`` and of its ``BuildSettings`` when it declares any; its ``skill``; and the ``data``
methods, ``install_steps`` and ``ready_checks`` it serves, by name."""
methods, ``install_steps`` and ``ready_checks`` it serves, by name; and ``browser``, true for a website browser."""

model_config = ConfigDict(extra="forbid")

Expand All @@ -249,6 +249,7 @@ class AppDeclaration(BaseModel):
data: list[str]
install_steps: list[InstallStep]
ready_checks: list[AppReadyCheck]
browser: Optional[bool] = None

@model_validator(mode="before")
@classmethod
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import asyncio
import copy
import json
from pathlib import Path
from typing import Optional

Expand All @@ -16,7 +17,7 @@
from .. import client
from ..types import RPC_PATH, WELL_KNOWN_PATH, EnvironmentCard
from .app import COMPUTER_APP, FAILURE, SOCKET
from .app_runtime import CHECK_TIMEOUT_S, AppReadiness
from .app_runtime import CHECK_TIMEOUT_S, LAUNCHES, AppReadiness
from .operations import ReadyCheck

#: Where a composed computer installs its apps, a folder each.
Expand Down Expand Up @@ -54,7 +55,7 @@ async def check(folder: Path) -> ReadyCheck:
async def children(folders: list[Path]) -> list[EnvironmentCard]:
"""Each app in ``folders`` as a child environment of its computer: its server's card, rooted at ``/svc/<name>/``,
or, while its server isn't answering, its name and route alone."""
cards = await asyncio.gather(*(_card(folder) for folder in folders))
cards = await asyncio.gather(*(served_card(folder) for folder in folders))
return [EnvironmentCard.model_validate(child_card(folder.name, card)) if card is not None
else EnvironmentCard(name=folder.name, url=f"/svc/{folder.name}{RPC_PATH}")
for folder, card in zip(folders, cards)]
Expand Down Expand Up @@ -85,7 +86,7 @@ def rooted(path: object) -> object:

async def owned_paths(folders: list[Path]) -> list[str]:
"""The paths under the session user's home that the apps in ``folders`` keep their data in, as their cards say."""
cards = await asyncio.gather(*(_card(folder) for folder in folders))
cards = await asyncio.gather(*(served_card(folder) for folder in folders))
return [path for card in cards if card is not None
for path in client.extension_params(card, COMPUTER_APP).get("paths") or []]

Expand All @@ -104,7 +105,7 @@ def endpoint(card: dict, method: str) -> str:
return client.find_extension_method(card, COMPUTER_APP, method)["endpoint"]


async def _card(folder: Path) -> Optional[dict]:
async def served_card(folder: Path) -> Optional[dict]:
"""The card the app in ``folder`` serves, or None while its server isn't answering."""
try:
async with _client(folder / SOCKET) as http:
Expand All @@ -113,6 +114,33 @@ async def _card(folder: Path) -> Optional[dict]:
return None


def ended_on_its_own(folder: Path) -> Optional[str]:
"""Why the app in ``folder`` can't be ready again where nothing launches it but its server: its launch log says it
ended on its own, an infrastructure failure; None while it hasn't."""
for entry in launch_log(folder):
if entry.get("event") == "exit":
how = (f"by signal {entry['signal']}" if "signal" in entry else
f"with code {entry['code']}" if "code" in entry else "in a way that wasn't recorded")
return (f"it ended on its own at {entry.get('at')}, {how}, and nothing launches it again: an "
"infrastructure failure")
return None


def launch_log(folder: Path) -> list[dict]:
"""Each entry of the launch log of the app in ``folder`` that's whole, in order."""
try:
lines = (folder / LAUNCHES).read_text(encoding="utf-8").splitlines()
except FileNotFoundError:
return []
entries = []
for line in lines:
try:
entries.append(json.loads(line))
except ValueError:
continue
return entries


def _unanswered(folder: Path) -> str:
try:
return f"its server failed to start: {(folder / FAILURE).read_text()}"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ def computer_features(driver: ComputerDriverEnvironment) -> dict:
"max_wait_ms": driver.max_wait_ms, "max_drag_points": driver.max_drag_points,
"max_duration_ms": driver.max_duration_ms, "max_key_repeat": driver.max_key_repeat,
"max_scroll_amount": driver.max_scroll_amount, "delivery": "foreground", "platform": driver.platform.as_dict(),
"supports": list(driver.supports), "default_data_paths": list(DEFAULT_DATA_PATHS),
"supports": list(driver.supports), "default_data_paths": list(DEFAULT_DATA_PATHS), "screen": driver.screen_kind,
}


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ def check_features(cls: type) -> None:
problems.append("keys are W3C key values in their W3C case")
except AgentEnvError as e:
problems.append(f"keys: {e.message}")
if cls.screen_kind not in ("desktop", "page"):
problems.append(f"screen_kind is desktop or page, got {cls.screen_kind!r}")
if ("set_display" in declared(cls)) != bool(cls.display_modes):
problems.append("display_modes and @set_display come together")
problems += check_recording(cls.recording)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from __future__ import annotations

import asyncio
from typing import Any, Callable, ClassVar
from typing import Any, Callable, ClassVar, Literal

import httpx
from pydantic import BaseModel, ConfigDict
Expand Down Expand Up @@ -79,6 +79,9 @@ class ComputerDriverEnvironment(Tools, Extensions, DataPlane, AgentEnvEnvironmen
recording: ClassVar[Recording | None] = Recording()
#: What an agent should know about this driver's computer beyond what its card says, as markdown: the end of its skill.
notes: ClassVar[str] = ""
#: What the computer's screen shows, its card's ``screen``: a ``desktop``, or a website browser's ``page``, its
#: active tab.
screen_kind: ClassVar[Literal["desktop", "page"]] = "desktop"
tool_access = True
#: What the base class keeps for the driver, built by ``__init__``.
_computer: ComputerRuntime | None = None
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,12 @@ async def _computer_skill(self) -> tuple[str, str, dict[str, str]]:
description="Whether the computer is usable now, by real checks; never sends input.")
async def _computer_ready_status(self) -> dict:
checks = await self._computer.ready_checks()
return {"ready": all(c.ok for c in checks),
"checks": [{"name": c.name, "ok": c.ok, "detail": c.detail} for c in checks]}
status = {"ready": all(c.ok for c in checks),
"checks": [{"name": c.name, "ok": c.ok, "detail": c.detail, **({"final": True} if c.final else {})}
for c in checks]}
if failed := [c for c in checks if c.final and not c.ok]:
status["failed"] = "; ".join(f"{c.name}: {c.detail}" for c in failed)
return status

@extension(COMPUTER_DISPLAY, op="get", method="GET", description="Get or set the display's resolution and scale.")
async def _computer_display_get(self) -> dict:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ class ReadyCheck:
name: str
ok: bool
detail: str = ""
#: Failed for good: the check can't pass again, so the computer has failed.
final: bool = False


@dataclass(frozen=True)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,15 @@ def as_dict(self) -> dict:

def detect(environ: dict | None = None, os_release: Path = Path("/etc/os-release")) -> Platform:
"""The running platform: the OS, ``/etc/os-release``'s ID and VERSION_ID on Linux, the machine, and the
display from ``XDG_SESSION_TYPE``, else ``DISPLAY`` (x11) or ``WAYLAND_DISPLAY`` (wayland)."""
display from ``XDG_SESSION_TYPE``, ``none`` for a computer with no display, else ``DISPLAY`` (x11) or
``WAYLAND_DISPLAY`` (wayland)."""
environ = os.environ if environ is None else environ
name = _OS.get(sys.platform, sys.platform)
release = _os_release(os_release) if name == "linux" else {}
display = None
if name == "linux":
session = (environ.get("XDG_SESSION_TYPE") or "").strip().lower()
if session in ("x11", "wayland"):
if session in ("x11", "wayland", "none"):
display = session
elif environ.get("DISPLAY"):
display = "x11"
Expand Down
Loading
Loading