Skip to content
Open
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
2 changes: 1 addition & 1 deletion AGENTS.md

Large diffs are not rendered by default.

27 changes: 27 additions & 0 deletions packages/agentenv-protocol/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,33 @@ async with ToolSession(mcp_url=mcp_url, role="alice@example.com") as session:
- **Tools:** `list_tools()` returns the tools the role may use as `EnvironmentTool`, the type a card's
`capabilities.tools` holds. Each `inputSchema` is the JSON Schema a model API takes as the tool's parameters.

## Website readiness

`agentenv_protocol.website` is the one definition of whether a website env is healthy. A site's card advertises
`urn:agentenv:website/v1`'s `ready` (`WEBSITE_READY_OP`), a GET answering a `WebsiteReadiness`: `ready`, a boolean,
and `problems`, the names of what keeps it from being ready (a `WebsiteEnvironment`'s are its `@ready_check` methods
not yet true). The model is strict about those two (`"yes"` is no `ready`) and ignores fields it doesn't know, so a
reader keeps reading a newer site. Everything that judges a site reads it through these helpers: agent-env's deploy,
`DeployedEnv.readiness()`, and the conformance checker's `health`, which fails an answer that isn't a
`WebsiteReadiness`.

```python
from agentenv_protocol.website import env_readiness, website_readiness

site = await website_readiness(base_url, card) # WebsiteReadiness, or None for a card without ready
env = await env_readiness(gateway_url, env_card) # WebsiteReadiness of every site of the env
print(env.ready, env.problems) # False ['shop: seeded']
```

- **One site:** `website_readiness(base_url, card, *, timeout=READY_TIMEOUT_S, headers=None)` asks the site as its card
advertises `ready`. It raises httpx's error when the site can't be reached, `ExtensionCallError` for a non-2xx
answer and `ValueError` (pydantic's `ValidationError`) for an answer that isn't a `WebsiteReadiness`.
- **An env:** `env_readiness(base_url, card, ...)` asks, at once, the card itself when it advertises `ready` (a single
site's card), and every child under a composed card's `children_environments` that does, its endpoints rooted at
`base_url` as a gateway's are. A child's problems are prefixed with its name (`shop: seeded`); one that can't be
asked, or answers no `WebsiteReadiness`, is a problem itself. A card or child without `ready`, an MCP server's or an
older site's, isn't counted, so an env with no site serving it is ready with no problems.

## Computer drivers

A computer driver is what runs inside a computer an agent uses through screenshots and input. Write
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""The website SDK: a site's server, meeting ``urn:agentenv:website/v1``. Subclass WebsiteEnvironment, declare the
site with ``@website(domains=[...], static="dist/")``, mark its API with ``@api.get/post/put/patch/delete(path)``, and
``serve()`` it; it needs only the plain package, and uvicorn to serve. The class is optional: a server in any language
that meets the contract works, which ``python -m agentenv_protocol.website.conformance <url>`` checks. Whether a site
is ready is its ``WebsiteReadiness`` answer to ``ready``, which ``website_readiness`` reads of one site and
``env_readiness`` of every site of an env. What a task left a site in is its ``WebsiteState`` answer to ``state``,
which ``website_state`` reads of one site of those ``website_sites`` names."""

from __future__ import annotations

from .clock import CLOCK_EXTENSION_URI, UNIVERSE_NOW_HEADER, UniverseClock
from .environment import (
DEFAULT_HEALTH_PATH,
SESSION_PATH,
STATE_PATH,
WebsiteDeclaration,
WebsiteEnvironment,
ready_check,
website,
)
from .errors import ApiError, BadRequest, Conflict, Forbidden, NotFound, Unauthorized
from .readiness import READY_TIMEOUT_S, WEBSITE_EXTENSION_URI, WEBSITE_READY_OP, WebsiteReadiness, env_readiness, website_readiness
from .routes import ROLE_KEY_ENV, ROLE_KEY_HEADER, ApiContext, ApiRoute, api, reserved_path
from .session import SESSION_COOKIE, SessionSigner
from .state import STATE_TIMEOUT_S, WEBSITE_STATE_OP, WebsiteState, advertises_state, website_sites, website_state
from .store import Collections

__all__ = [
"WebsiteEnvironment", "WebsiteDeclaration", "website", "api", "ApiContext", "ApiRoute", "ready_check",
"ApiError", "BadRequest", "Unauthorized", "Forbidden", "NotFound", "Conflict",
"Collections", "UniverseClock", "SessionSigner", "reserved_path",
"WEBSITE_EXTENSION_URI", "CLOCK_EXTENSION_URI", "UNIVERSE_NOW_HEADER", "DEFAULT_HEALTH_PATH", "SESSION_PATH",
"STATE_PATH", "SESSION_COOKIE", "ROLE_KEY_ENV", "ROLE_KEY_HEADER",
"WEBSITE_READY_OP", "READY_TIMEOUT_S", "WebsiteReadiness", "website_readiness", "env_readiness",
"WEBSITE_STATE_OP", "STATE_TIMEOUT_S", "WebsiteState", "advertises_state", "website_sites", "website_state",
]
114 changes: 114 additions & 0 deletions packages/agentenv-protocol/src/agentenv_protocol/website/clock.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
"""The universe's clock as a site reads it: ``clock/v1``'s ``sync_time`` hands the server the gateway's ``/clock/time``
URL, as it hands it to MCP servers, and the site's time then follows that clock's ``{"virtual_time": <RFC3339>}``,
read at most once a second and extrapolated between reads. The rate it extrapolates at is one two successive intervals
between reads agreed on, so a clock set or advanced between two reads is taken for a jump, not a speed; until two agree
it is real time's. An unsynced site reads wall time. Every answer of a site carries the time it answered at as ``X-Universe-Now``,
RFC3339 in UTC. ``sync_time`` may also name the universe's time zone, an IANA name, which the clock keeps for the site
to show local times in; the time itself stays UTC."""
from __future__ import annotations

import asyncio
import datetime
import json
import logging
import time

import httpx

logger = logging.getLogger(__name__)

CLOCK_EXTENSION_URI = "urn:agentenv:clock/v1"
UNIVERSE_NOW_HEADER = "X-Universe-Now"
REFRESH_S = 1.0
READ_TIMEOUT_S = 5.0
RATE_AGREEMENT = 0.1


def iso(moment: datetime.datetime) -> str:
return moment.astimezone(datetime.timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")


def parse_time(value: str) -> datetime.datetime:
moment = datetime.datetime.fromisoformat(value.replace("Z", "+00:00"))
return moment if moment.tzinfo else moment.replace(tzinfo=datetime.timezone.utc)


class UniverseClock:
"""The time a site answers with: wall time until ``sync`` names a gateway clock, that clock's after."""

def __init__(self) -> None:
self.url: str | None = None
self.timezone: str | None = None
self._virtual: datetime.datetime | None = None
self._read_at = 0.0
self._tried_at = 0.0
self._rate = 1.0
self._measured: float | None = None
self._lock: asyncio.Lock | None = None

@property
def synced(self) -> bool:
return self._virtual is not None

def now(self) -> datetime.datetime:
if self._virtual is None:
return datetime.datetime.now(datetime.timezone.utc)
return self._virtual + datetime.timedelta(seconds=(time.monotonic() - self._read_at) * self._rate)

async def sync(self, url: str, timezone: str | None = None) -> datetime.datetime:
"""Follow the clock at ``url`` from now on, in ``timezone`` (none when the sync names none); raises when it
can't be read, keeping the clock and zone it followed. It waits for a refresh reading the clock it followed,
so that read never lands on the new clock."""
async with self._reading():
virtual = await _read(url)
self.url, self._virtual, self._rate, self._measured = url, virtual, 1.0, None
self.timezone = timezone
self._read_at = self._tried_at = time.monotonic()
return virtual

async def refresh(self) -> None:
"""Read the synced clock again when the last read is a second old; a failed read keeps extrapolating."""
if self.url is None or time.monotonic() - self._tried_at < REFRESH_S:
return
async with self._reading():
started = time.monotonic()
if started - self._tried_at < REFRESH_S:
return
self._tried_at = started
try:
virtual = await _read(self.url)
except Exception as e:
logger.debug("reading the universe's clock at %s failed: %r", self.url, e)
return
read_at = time.monotonic()
if self._virtual is not None and read_at > self._read_at:
measured = max(0.0, (virtual - self._virtual).total_seconds() / (read_at - self._read_at))
if self._measured is not None and abs(measured - self._measured) <= RATE_AGREEMENT * max(measured, self._measured, 1.0):
self._rate = measured
self._measured = measured
self._virtual, self._read_at = virtual, read_at

def _reading(self) -> asyncio.Lock:
"""The lock a sync and a refresh each read the clock under."""
self._lock = self._lock or asyncio.Lock()
return self._lock


async def _read(url: str) -> datetime.datetime:
async with httpx.AsyncClient() as client:
response = await client.get(url, timeout=READ_TIMEOUT_S)
response.raise_for_status()
return parse_time(response.json()["virtual_time"])


def clock_patch_script(now: datetime.datetime) -> bytes:
"""An inline script that shifts the page's ``Date`` to the universe's time ``now``, and follows the
``X-Universe-Now`` header of every ``fetch`` the page makes after. The ``Date`` it puts in place answers called,
with the time's string, as constructed, and keeps ``Date.parse``, ``Date.UTC`` and ``instanceof Date``."""
return ("<script>(function(){var D=Date,o=%s-D.now();function n(){return D.now()+o}"
"function U(){if(!new.target)return new D(n()).toString();"
"return Reflect.construct(D,arguments.length?[].slice.call(arguments):[n()],new.target)}"
"Object.setPrototypeOf(U,D);U.prototype=D.prototype;D.prototype.constructor=U;U.now=n;U.parse=D.parse;"
"U.UTC=D.UTC;window.Date=U;var f=window.fetch;if(f)window.fetch=function(){return f.apply(this,arguments)"
".then(function(r){var t=D.parse(r.headers.get(%s)||'');if(!isNaN(t))o=t-D.now();return r})}})();"
"</script>" % (int(now.timestamp() * 1000), json.dumps(UNIVERSE_NOW_HEADER))).encode()
Loading
Loading