Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
7c9dfc4
feat(website): WebsiteEnvironment serves a site on the website/v1 con…
williamloo-scale Oct 31, 2026
e19a8d3
fix(website): a site believes AgentEnv-Role only with its WEBSITE_ROL…
williamloo-scale Nov 2, 2026
28452d1
Merge wloo/website-harness-gaps into wloo/website-environment-sdk
williamloo-scale Nov 2, 2026
425ade0
fix(website): drop the conformance test's unused json import (FD-4683)
williamloo-scale Nov 2, 2026
01ab058
Merge wloo/website-harness-gaps into wloo/website-environment-sdk
williamloo-scale Nov 2, 2026
5d5303e
feat(website): a site's sync_time takes the universe's time zone (FD-…
williamloo-scale Nov 2, 2026
ae2b015
Merge wloo/website-harness-gaps into wloo/website-environment-sdk
williamloo-scale Nov 2, 2026
c51f4e3
Merge wloo/website-harness-gaps into wloo/website-environment-sdk
williamloo-scale Nov 3, 2026
5ccdf83
feat(website): a site says it's ready by its card's website/v1 ready …
williamloo-scale Nov 3, 2026
2318642
fix(website): a body is validated and described by its whole annotati…
williamloo-scale Nov 3, 2026
eb2da70
fix(website): /go and /api/session answer before a broad @api route t…
williamloo-scale Nov 3, 2026
151f1a9
fix(website): a refresh of the old clock never overwrites a sync to a…
williamloo-scale Nov 3, 2026
08a5161
fix(website): the clock script's Date answers called as well as const…
williamloo-scale Nov 3, 2026
28d9853
fix(website): conformance leaves a server's state as it found it, che…
williamloo-scale Nov 3, 2026
225c688
Merge wloo/website-harness-gaps into wloo/website-environment-sdk
williamloo-scale Nov 3, 2026
a6414c2
fix(website): conformance counts a data request as answered only on a…
williamloo-scale Nov 3, 2026
26e5305
fix(website): the OpenAPI document describes answers as serialized an…
williamloo-scale Nov 3, 2026
605071c
Merge wloo/website-harness-gaps into wloo/website-environment-sdk
williamloo-scale Nov 3, 2026
f940a20
fix(website): a bodyless status publishes no body, and no @api route …
williamloo-scale Nov 3, 2026
6e4e5a0
Merge wloo/website-harness-gaps into wloo/website-environment-sdk
williamloo-scale Nov 3, 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""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."""

from __future__ import annotations

from .clock import CLOCK_EXTENSION_URI, UNIVERSE_NOW_HEADER, UniverseClock
from .environment import (
DEFAULT_HEALTH_PATH,
SESSION_PATH,
STATE_PATH,
WEBSITE_EXTENSION_URI,
WebsiteDeclaration,
WebsiteEnvironment,
ready_check,
website,
)
from .errors import ApiError, BadRequest, Conflict, Forbidden, NotFound, Unauthorized
from .routes import ROLE_KEY_ENV, ROLE_KEY_HEADER, ApiContext, ApiRoute, api, reserved_path
from .session import SESSION_COOKIE, SessionSigner
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",
]
113 changes: 113 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,113 @@
"""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


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) <= 0.1 * max(measured, self._measured, 1.0):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Settings lack descriptive names

The new clock comparison uses an unnamed 0.1 tolerance. The repository requires magic numbers to be stored as class or instance variables with descriptive names. Name this tolerance so readers can see what it controls. The 32 passed to secrets.token_bytes() in session.py has the same problem.

This repository requirement must be satisfied before merging.

Rule Used: Store magic numbers as class or instance variables with descriptive names rather than using them inline in the code. (source)

Learned From
scaleapi/scaleapi#126388

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/agentenv-protocol/src/agentenv_protocol/website/clock.py
Line: 85

Comment:
**Settings lack descriptive names**

The new clock comparison uses an unnamed `0.1` tolerance. The repository requires magic numbers to be stored as class or instance variables with descriptive names. Name this tolerance so readers can see what it controls. The `32` passed to `secrets.token_bytes()` in `session.py` has the same problem.

This repository requirement must be satisfied before merging.

**Rule Used:** Store magic numbers as class or instance variables with descriptive names rather than using them inline in the code. ([source](https://app.greptile.com/-/switch-tenant?tenant=scale-ai&next=%2Fcustom-context%3Fmemory%3D002e0051-41ad-46c1-9098-47433c580150))

**Learned From**
[scaleapi/scaleapi#126388](https://github.com/scaleapi/scaleapi/pull/126388)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Cursor Fix in Claude Code Fix in Codex

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