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
4 changes: 2 additions & 2 deletions AGENTS.md

Large diffs are not rendered by default.

31 changes: 31 additions & 0 deletions packages/agentenv-protocol/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,37 @@ 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,
`problems`, what keeps it from being ready, and optionally `waited_s` since its server started and the `timeout_s` it
may take (`SITE_READY_TIMEOUT_S` when it says nothing), which is how long a deploy waits for it. A
`WebsiteEnvironment` answers from its `@ready_check` methods, run at once, each within `CHECK_TIMEOUT_S` and a plain
`def` in a worker thread, and from `@website(ready_timeout_s=...)`; it serves no separate health path. The model is
strict about `ready` and `problems` (`"yes"` is no `ready`) and ignores fields it doesn't know, so a reader keeps
reading a newer site. A server whose card has no `ready` is still read at its health path (`DEFAULT_HEALTH_PATH`
unless it names one), which is deprecated. 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,41 @@
"""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 with is its data plane's ``data/get``."""

from __future__ import annotations

from .clock import CLOCK_EXTENSION_URI, UNIVERSE_NOW_HEADER, ClockUnavailable, UniverseClock
from .environment import (
SESSION_PATH,
WebsiteDeclaration,
WebsiteEnvironment,
host_problem,
ready_check,
website,
)
from .errors import ApiError, BadRequest, Conflict, Forbidden, NotFound, Unauthorized
from .readiness import (
DEFAULT_HEALTH_PATH,
READY_TIMEOUT_S,
SITE_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, SESSION_SECRET_ENV, SessionSigner

__all__ = [
"WebsiteEnvironment", "WebsiteDeclaration", "website", "api", "ApiContext", "ApiRoute", "ready_check", "host_problem",
"ApiError", "BadRequest", "Unauthorized", "Forbidden", "NotFound", "Conflict",
"UniverseClock", "ClockUnavailable", "SessionSigner", "reserved_path",
"WEBSITE_EXTENSION_URI", "CLOCK_EXTENSION_URI", "UNIVERSE_NOW_HEADER", "DEFAULT_HEALTH_PATH", "SESSION_PATH",
"SESSION_COOKIE", "SESSION_SECRET_ENV", "ROLE_KEY_ENV", "ROLE_KEY_HEADER",
"WEBSITE_READY_OP", "READY_TIMEOUT_S", "SITE_READY_TIMEOUT_S", "WebsiteReadiness",
"website_readiness", "env_readiness",
]
122 changes: 122 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,122 @@
"""The universe's clock as a site reads it, the way MCP servers read it: ``clock/v1``'s ``sync_time`` hands the server
the gateway's ``/clock/time`` URL, and each request then reads that clock's ``{"virtual_time": <RFC3339>}`` once,
the one instant everything the request does reads (``now()``) and its answer carries as ``X-Universe-Now``, RFC3339 in
UTC. An unsynced site reads wall time, and so does a synced one whose gateway clock isn't armed (404); one whose clock
can't be read raises ``ClockUnavailable``. ``sync_time`` with an empty URL goes back to wall time. ``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 contextvars
import datetime
import json
import re

import httpx

CLOCK_EXTENSION_URI = "urn:agentenv:clock/v1"
UNIVERSE_NOW_HEADER = "X-Universe-Now"
READ_TIMEOUT = httpx.Timeout(connect=0.5, read=1.0, write=1.0, pool=0.5)
READ_ATTEMPTS = 2

_FRACTION = re.compile(r"(\.\d+)(?=[+-]\d\d:?\d\d$|Z$|$)")
_request_now: contextvars.ContextVar[datetime.datetime | None] = contextvars.ContextVar("universe_now", default=None)


class ClockUnavailable(Exception):
"""The synced clock couldn't be read: unreachable, a 5xx after a retry, or an answer that isn't a time."""


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:
"""An RFC3339 time, its fraction of a second of any length (Python 3.10 parses only 3 or 6 digits)."""
value = _FRACTION.sub(lambda m: m.group(1)[:7].ljust(7, "0"), value.replace("Z", "+00:00"))
moment = datetime.datetime.fromisoformat(value)
return moment if moment.tzinfo else moment.replace(tzinfo=datetime.timezone.utc)


def _wall() -> datetime.datetime:
return datetime.datetime.now(datetime.timezone.utc)


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

def __init__(self) -> None:
self.url: str | None = None
self.timezone: str | None = None
self._last: datetime.datetime | None = None

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

def now(self) -> datetime.datetime:
"""The instant the request being answered read, else the last one read, else wall time."""
return _request_now.get() or self._last or _wall()

async def read(self) -> datetime.datetime:
"""The universe's time now: the synced clock's, wall time when none is synced or it isn't armed; raises
``ClockUnavailable`` when it can't be read."""
if self.url is None:
return _wall()
self._last = await _read(self.url) or _wall()
return self._last

def answering_at(self, moment: datetime.datetime) -> contextvars.Token:
"""Make ``moment`` what ``now()`` reads while the current request is answered."""
return _request_now.set(moment)

def answered(self, token: contextvars.Token) -> None:
_request_now.reset(token)

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), or wall time again
for an empty ``url``; raises ``ClockUnavailable`` when the clock can't be read, keeping the clock and zone it
followed."""
if not url:
self.url, self.timezone, self._last = None, None, None
return _wall()
virtual = await _read(url) or _wall()
self.url, self.timezone, self._last = url, timezone, virtual
return virtual


async def _read(url: str) -> datetime.datetime | None:
"""The clock at ``url``'s time, None when it isn't armed (404); a transport error or a 5xx is tried again once."""
failure: Exception | None = None
for _attempt in range(READ_ATTEMPTS):
try:
async with httpx.AsyncClient(timeout=READ_TIMEOUT) as client:
response = await client.get(url)
except httpx.TransportError as e:
failure = e
continue
if response.status_code == 404:
return None
if response.status_code >= 500:
failure = httpx.HTTPStatusError(f"{response.status_code}", request=response.request, response=response)
continue
try:
response.raise_for_status()
return parse_time(response.json()["virtual_time"])
except (httpx.HTTPStatusError, ValueError, KeyError, TypeError) as e:
raise ClockUnavailable(f"the universe's clock at {url} answered {response.status_code}: {e}") from e
raise ClockUnavailable(f"the universe's clock at {url} can't be read: {type(failure).__name__}: {failure}")


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