diff --git a/pyproject.toml b/pyproject.toml index dffa085a5..0038f7999 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -143,6 +143,7 @@ hello = "agent_env.examples" echo-mcp = "agent_env.examples" mail-mcp = "agent_env.examples" next-env-event = "agent_env.examples" +advance-to-next-event = "agent_env.examples" [project.urls] Homepage = "https://www.agentenvframework.com" diff --git a/src/agent_env/env/gateway/constants.py b/src/agent_env/env/gateway/constants.py index a36db339a..c20c5de23 100644 --- a/src/agent_env/env/gateway/constants.py +++ b/src/agent_env/env/gateway/constants.py @@ -78,6 +78,7 @@ def data_plane_load_timeout_s(size_bytes: int | None) -> int: EXT_STEP_URI = "urn:agentenv:step/v1" EXT_STATE_URI = "urn:agentenv:state/v1" EXT_CHANGES_URI = "urn:agentenv:changes/v1" +EXT_NOTIFICATIONS_URI = "urn:agentenv:notifications/v1" # Rows per GET /changes page: the default, and the most a client may ask for. CHANGES_PAGE_DEFAULT = 1000 @@ -122,8 +123,10 @@ def sources(names: list[str]) -> str: TRIGGER_WEBSITE = "website" TRIGGER_EXECUTOR_HOURS_TIME_ZONE = "executor_hours_time_zone" TRIGGER_NEXT_EVENT_AT = "next_event_at" +TRIGGER_NOTIFY = "notify" TRIGGER_FEATURES = (TRIGGER_NAMED_EXECUTORS, TRIGGER_PROVOKING_ROLES, TRIGGER_ACTION_DELAY, TRIGGER_EXECUTOR_HOURS, - TRIGGER_UNLESS, TRIGGER_WEBSITE, TRIGGER_EXECUTOR_HOURS_TIME_ZONE, TRIGGER_NEXT_EVENT_AT) + TRIGGER_UNLESS, TRIGGER_WEBSITE, TRIGGER_EXECUTOR_HOURS_TIME_ZONE, TRIGGER_NEXT_EVENT_AT, + TRIGGER_NOTIFY) # The one `source` a /step call may name instead of step_api, with the id of the hook artifact that made it. STEP_SOURCE_POST_TURN_HOOK = "post_turn_hook" @@ -244,4 +247,18 @@ def random_mcp_server_name() -> str: "limit": {"type": "integer", "minimum": 1, "maximum": CHANGES_PAGE_MAX}}}}}}, "required": False, }, + { + "uri": EXT_NOTIFICATIONS_URI, + "description": "Notifications (urn:agentenv:notifications/v1): what comes due for a role between its turns, on the virtual clock, each for one recipient (`to`): checks a role scheduled for itself (schedule_check, which only the roles config names see) and notifications triggers sent (the notify action, to the default role unless it names another). state says when the next is due; take hands a recipient each due one once (harness control path), every item one shape: id, to, at, times, source (scheduled_check or trigger), source_id, channel (the label the agent sees) and parts. config replaces all settings (omitted fields reset to defaults, so one without roles turns checks off) and leaves existing checks as they are unless it drops their role: lowering max_active leaves extra active checks in place, and raising min_interval leaves existing repeats unchanged. clear resets the settings to their defaults and drops every check and notification. Unrelated to A2A push notifications.", + "params": {"endpoint": "/notifications/state", "methods": { + "config": {"method": "POST", "endpoint": "/notifications/config", "request": {"type": "object", "properties": { + "roles": {"type": "array", "items": {"type": "string"}}, "max_active": {"type": "integer", "minimum": 1}, + "min_interval": {"type": "string"}}}}, + "state": {"method": "GET", "endpoint": "/notifications/state", "request": {"type": "object", "properties": { + "to": {"type": "string"}}}}, + "take": {"method": "POST", "endpoint": "/notifications/take", "request": {"type": "object", "properties": { + "to": {"type": "string"}, "until": {"type": "string"}}}}, + "clear": {"method": "POST", "endpoint": "/notifications/clear", "request": {"type": "object", "properties": {}}}}}, + "required": False, + }, ] diff --git a/src/agent_env/env/gateway/gateway.py b/src/agent_env/env/gateway/gateway.py index 58efa78c6..e620645c3 100644 --- a/src/agent_env/env/gateway/gateway.py +++ b/src/agent_env/env/gateway/gateway.py @@ -73,6 +73,7 @@ from .get_time import GET_TIME_TOOL_NAME, build_get_time_tool from .rest_proxy import ROLE_HEADER_BYTES, UpstreamResponse, fetch_child_card, forward, rewrite_child_card from .triggers import TriggerEngine, TriggerError +from .notifications import CHECK_TOOL_NAMES, NotificationError, Notifications, build_check_tools logger = logging.getLogger(__name__) @@ -173,6 +174,7 @@ async def list_website_urls() -> list[str]: ) ) self._clock = Clock() + self._notifications = Notifications(lambda: self._clock) self._env_get_time_url_cache: str | None = None self._install_role_filters() @@ -314,6 +316,61 @@ async def clock_state(request: Request) -> Response: st["env_get_time_url"] = await self._env_get_time_url() return self._json_response(st, 200) + async def _notifications_write(request: Request, method) -> Response: + try: + body = await request.json() + except Exception: + return self._json_response({"ok": False, "error": "Invalid JSON body"}, 400) + try: + return self._json_response(method(body), 200) + except NotificationError as e: + return self._json_response({"ok": False, "error": str(e)}, 400) + + @self._mcp.custom_route("/notifications/config", methods=["POST"]) + async def notifications_config(request: Request) -> Response: + try: + body = await request.json() + except Exception: + return self._json_response({"ok": False, "error": "Invalid JSON body"}, 400) + # A config that names roles registers the tools, so the backing servers' tools must be known (or a later + # discovery would replace ours unseen) and none may have their names. One that turns checks off + # (roles: [], the default) registers nothing, so neither can block it. + if not (isinstance(body, dict) and body.get("roles", []) == []): + try: + await self._ensure_tools_discovered() + except Exception as e: + return self._json_response( + {"ok": False, "error": f"tool discovery has not completed ({type(e).__name__}); retry"}, 503) + taken = sorted(set(CHECK_TOOL_NAMES) & set(self._tool_server_urls)) + if taken: + return self._json_response( + {"ok": False, "error": f"{taken} already registered by a backing server"}, 409) + try: + state = self._notifications.configure(body) + except NotificationError as e: + return self._json_response({"ok": False, "error": str(e)}, 400) + if self._notifications.enabled: + self._register_check_tools() + else: + self._unregister_check_tools() + return self._json_response(state, 200) + + @self._mcp.custom_route("/notifications/state", methods=["GET"]) + async def notifications_state(request: Request) -> Response: + try: # ?to= for one recipient's; every recipient's without it + return self._json_response(self._notifications.state(request.query_params.get("to")), 200) + except NotificationError as e: + return self._json_response({"ok": False, "error": str(e)}, 400) + + @self._mcp.custom_route("/notifications/take", methods=["POST"]) + async def notifications_take(request: Request) -> Response: + return await _notifications_write(request, self._notifications.take) + + @self._mcp.custom_route("/notifications/clear", methods=["POST"]) + async def notifications_clear(request: Request) -> Response: + self._unregister_check_tools() + return self._json_response(self._notifications.clear(), 200) + @self._mcp.custom_route("/trajectory", methods=["GET"]) async def env_trajectory(request: Request) -> Response: return self._serve_trajectory() @@ -425,7 +482,10 @@ def _listen_socket(self, family: int, host: str) -> socket.socket: def _is_disabled(self, role: str, name: str) -> bool: """Walk the rule chain (role first, then WILDCARD) returning the first concrete True/False; default False. - A tool listed hidden by default skips "*"-tool rules: only rules naming it apply, and with none it is hidden.""" + A tool listed hidden by default skips "*"-tool rules: only rules naming it apply, and with none it is hidden. + The scheduled-check tools exist only for the roles the notifications config names, whatever the rules say.""" + if name in CHECK_TOOL_NAMES and name not in self._tool_server_urls and role not in self._notifications.roles: + return True hidden = name in self._hidden_by_default for r in (role, WILDCARD): rules = self._role_rules.get(r, {}) @@ -901,9 +961,11 @@ async def _discover_and_register_tools(self, sessions: dict[str, ClientSession]) raise logger.info(f"Found {len(tools_result.tools)} tool(s) from {server.name}") listed.append((server, tools_result.tools)) - # get_time is left out: a backing server's tool of that name wins, and the clock refuses to arm over it + # get_time and the scheduled-check tools are left out: a backing server's tool of that name wins, and the + # clock and the checks refuse to turn on over it sources: dict[str, list[str]] = { - tool.name: ["gateway"] for tool in self._server_tools.get("gateway", []) if tool.name != GET_TIME_TOOL_NAME} + tool.name: ["gateway"] for tool in self._server_tools.get("gateway", []) + if tool.name != GET_TIME_TOOL_NAME and tool.name not in CHECK_TOOL_NAMES} for server, tools in listed: for tool in tools: sources.setdefault(tool.name, []).append(server.name) @@ -923,6 +985,8 @@ async def _discover_and_register_tools(self, sessions: dict[str, ClientSession]) self._tools_discovered = True if self._mirror_get_time_descriptor(): logger.info(f"Re-mirrored '{GET_TIME_TOOL_NAME}' descriptor after tool discovery") + if self._mirror_check_descriptors(): + logger.info("Re-mirrored the scheduled-check tool descriptors after tool discovery") # list_website_urls sits in _server_tools from construction, so a memo may predate the backing tools self._trigger_engine.invalidate_readonly_cache() @@ -1083,7 +1147,12 @@ async def _step_call_gateway_tool(self, tool_name: str, arguments: dict, role: s }) try: async with self._tool_call_guard(): - result = await tool.run(arguments) + # bound as the MCP path binds it, so a tool that acts for its caller (schedule_check) reads the role + token = Caller(role=role, session=Caller.current().session).bind() + try: + result = await tool.run(arguments) + finally: + Caller.reset(token) changelog_id = self._query_changelog_id() if isinstance(result, CallToolResult): response_data = result.model_dump(mode="json", exclude_none=True) @@ -1148,6 +1217,48 @@ def _unregister_get_time_tool(self) -> None: self._server_tools.pop("gateway", None) self._trigger_engine.invalidate_readonly_cache() + def _register_check_tools(self) -> None: + """Expose schedule_check and its siblings when a config names roles, rebuilt on each config so their + descriptions carry its max_active and min_interval.""" + self._unregister_check_tools() + tools = self._mcp._tool_manager._tools + for tool in build_check_tools(self._notifications, self._create_arg_model_from_schema): + if tool.name not in self._tool_server_urls: + tools[tool.name] = tool + if self._mirror_check_descriptors(): + self._trigger_engine.invalidate_readonly_cache() + + def _mirror_check_descriptors(self) -> bool: + """Mirror the registered scheduled-check tools into `_server_tools`, as `_mirror_get_time_descriptor` does for + get_time. Returns whether it appended.""" + if not self._tools_discovered or not self._notifications.enabled: + return False + mirrored = self._server_tools.setdefault("gateway", []) + present = {t.name for t in mirrored} + added = False + for name in CHECK_TOOL_NAMES: + tool = self._mcp._tool_manager._tools.get(name) + if tool is None or name in present or name in self._tool_server_urls: + continue + mirrored.append(MCPTool(name=tool.name, description=tool.description, + inputSchema=tool.parameters, annotations=tool.annotations)) + added = True + return added + + def _unregister_check_tools(self) -> None: + """Remove them when a task turns scheduled checks off or clears (idempotent); a backing server's are left.""" + tools = self._mcp._tool_manager._tools + for name in CHECK_TOOL_NAMES: + if name not in self._tool_server_urls: + tools.pop(name, None) + remaining = [t for t in self._server_tools.get("gateway", []) if t.name not in CHECK_TOOL_NAMES + or t.name in self._tool_server_urls] + if remaining: + self._server_tools["gateway"] = remaining + else: + self._server_tools.pop("gateway", None) + self._trigger_engine.invalidate_readonly_cache() + def _create_and_register_proxy_tool(self, server_url: str, tool: MCPTool) -> None: self._mcp._tool_manager._tools[tool.name] = self._proxy_tool(server_url, tool) diff --git a/src/agent_env/env/gateway/notifications.py b/src/agent_env/env/gateway/notifications.py new file mode 100644 index 000000000..a50a0bff4 --- /dev/null +++ b/src/agent_env/env/gateway/notifications.py @@ -0,0 +1,463 @@ +"""Notifications for an agent, due on the gateway's virtual clock (urn:agentenv:notifications/v1). + +Two kinds, each for one recipient (the AgentEnv-Role it goes to): +- checks a role schedules for itself with the schedule_check / list_scheduled_checks / cancel_scheduled_check + tools, which only the roles a task names in `configure` see; +- notifications a task's triggers send with the `notify` action, through a channel the task declares, to the + solver's role unless the action names another. + +Nothing here wakes anyone: prompt_agent `take`s what has come due for its agent's role after each turn and opens the +agent's next message with it, and a harness (a post-turn hook) may read `state` to learn when the next one is due and +move the clock there. Unrelated to A2A push notifications. + +`take` hands out both as one shape, with where each came from as data, so a consumer never branches on the source: + {"id", "to", "at", "times", "source": "scheduled_check" | "trigger", "source_id": the check's or trigger's id, + "channel": the label the agent sees ("your scheduled check", or the trigger's channel), "parts": [one text part]} +""" +from __future__ import annotations + +import threading +from datetime import datetime, timedelta +from typing import Any, Callable + +from mcp.server.fastmcp.tools import Tool as FastMCPTool +from mcp.server.fastmcp.utilities.func_metadata import FuncMetadata +from mcp.types import CallToolResult, TextContent, ToolAnnotations + +from .caller import Caller +from .clock import _ISO8601_DURATION_RE, Clock, ClockError, _iso, _parse_duration, _parse_rfc3339 +from .constants import DEFAULT_ROLE, WILDCARD + +SCHEDULE_CHECK_TOOL_NAME = "schedule_check" +LIST_SCHEDULED_CHECKS_TOOL_NAME = "list_scheduled_checks" +CANCEL_SCHEDULED_CHECK_TOOL_NAME = "cancel_scheduled_check" +CHECK_TOOL_NAMES = (SCHEDULE_CHECK_TOOL_NAME, LIST_SCHEDULED_CHECKS_TOOL_NAME, CANCEL_SCHEDULED_CHECK_TOOL_NAME) + +CHECK_CHANNEL = "your scheduled check" # the channel every check is delivered through; no trigger may use it +CHECK_SOURCE, TRIGGER_SOURCE = "scheduled_check", "trigger" + +DEFAULT_MAX_ACTIVE = 10 +DEFAULT_MIN_INTERVAL = "PT1H" +_MAX_NOTE_CHARS = 2000 +_MAX_CHANNEL_CHARS = 60 +_MAX_ROLE_CHARS = 100 +_MAX_TAKE_ID_CHARS = 128 +_KEPT_TAKES = 64 # take results kept for retries, oldest dropped first +_CONFIG_FIELDS = ("roles", "max_active", "min_interval") +_HISTORY = 50 # finished and cancelled checks state() still shows + +SCHEDULE_CHECK_DESCRIPTION = ( + "Schedule a check-in for yourself. You only run when you are messaged, so to look at something again later " + "(a reply you are waiting for, a listing to watch, a deadline), schedule a check: when it comes due you are " + "woken with your note, so write the note so it tells you what to do. Give `at` (a time, or a duration from now " + "such as PT6H) and/or `every` (a duration such as P1D, to repeat), with an optional `until`. Times are in this " + "environment's clock (see get_time); a time without a UTC offset is refused. You can have at most {max_active} " + "checks at once. Times you get back are in UTC." +) +_TIME_RULE = "a time with seconds and its UTC offset (2027-02-02T18:00:00-08:00), or a duration from now (PT6H)" +_DURATION_RULE = "a duration in weeks, days, hours, minutes or seconds (PT6H, P1D, P1W)" + + +def _schedule_schema(min_interval: str) -> dict[str, Any]: + """schedule_check's arguments, stating the rules the parser applies, with the configured shortest repeat.""" + return { + "type": "object", + "properties": { + "note": {"type": "string", "description": "What to do when the check comes due."}, + "at": {"type": "string", "description": f"When it first comes due: {_TIME_RULE}."}, + "every": {"type": "string", "description": ( + f"Repeat this often: {_DURATION_RULE}, at least {min_interval}. No months or years; P1D is exactly " + "24 hours, not the same local time each day.")}, + "until": {"type": "string", "description": ( + "Only with every: stop after this time, or after this duration from now (not from at).")}, + }, + "required": ["note"], + "additionalProperties": False, + } + + +_LIST_SCHEMA: dict[str, Any] = {"type": "object", "properties": {}, "additionalProperties": False} +_CANCEL_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": {"check_id": {"type": "string"}}, + "required": ["check_id"], + "additionalProperties": False, +} + + +class NotificationError(ValueError): + """A bad configuration or tool argument (a 400 on the harness routes, a tool error for the agent).""" + + +def _when(text: Any, now: datetime, field: str) -> datetime: + """A time with seconds and a UTC offset, or a duration counted from now; an error names `field`, the argument the + caller gave, not the clock's own virtual_time. A leading p of either case is read as a duration.""" + if isinstance(text, str) and text[:1] in ("P", "p"): + try: + return now + _duration(text, field) + except OverflowError: + raise NotificationError(f"{field} is too far away") + try: + return _parse_rfc3339(text) + except ClockError: + raise NotificationError(f"{field} must be {_TIME_RULE}, not {text!r}") + + +def _duration(text: Any, field: str) -> timedelta: + """A duration in the units the clock takes; an error names `field` and those units.""" + try: + return _parse_duration(text) + except (ClockError, OverflowError): + shape = _ISO8601_DURATION_RE.match(text) if isinstance(text, str) else None + if shape and any(shape.groups()): # the right shape, too big to count + raise NotificationError(f"{field} is too long") + raise NotificationError(f"{field} must be {_DURATION_RULE}, not {text!r}: written in capitals, with no " + "months or years, and only seconds may have a fraction") + + +class Notifications: + """The gateway's record of scheduled checks and trigger notifications; threadsafe. `clock` returns the gateway's + current Clock, read at each use so a swapped clock (tests) is followed.""" + + def __init__(self, clock: Callable[[], Clock]): + self._clock_of = clock + self._lock = threading.Lock() + self._roles: tuple[str, ...] = () # who sees the check tools; none until a task names them + self._max_active = DEFAULT_MAX_ACTIVE + self._min_interval = _parse_duration(DEFAULT_MIN_INTERVAL) + self._checks: dict[str, dict] = {} + self._queue: list[dict] = [] # what triggers sent, pending until taken + self._takes: dict[tuple[str, str], dict] = {} # recent take results by (to, take_id), so a retry gets the same batch + self._seq = 0 + + @property + def roles(self) -> tuple[str, ...]: + return self._roles + + @property + def max_active(self) -> int: + return self._max_active + + @property + def min_interval(self) -> str: + return _iso_duration(self._min_interval) + + @property + def enabled(self) -> bool: + """Whether any role has the scheduled-check tools.""" + return bool(self._roles) + + def configure(self, body: Any) -> dict: + """Name the roles that get the scheduled-check tools, with their limits; `roles: []` (the default) turns them + off. Notifications need no switch. A config replaces every setting: a field left out goes back to its default, + so one without roles turns checks off. Existing checks stay as they are unless their role is left out: + lowering max_active leaves extra active checks in place, and raising min_interval leaves existing repeats + unchanged.""" + if not isinstance(body, dict): + raise NotificationError("body must be a JSON object") + unknown = sorted(set(body) - set(_CONFIG_FIELDS)) + if unknown: + raise NotificationError(f"config takes {list(_CONFIG_FIELDS)}, not {unknown}") + roles = _roles(body.get("roles", [])) + max_active = body.get("max_active", DEFAULT_MAX_ACTIVE) + if isinstance(max_active, bool) or not isinstance(max_active, int) or max_active < 1: + raise NotificationError("max_active must be a positive integer") + try: + min_interval = _parse_duration(body.get("min_interval", DEFAULT_MIN_INTERVAL)) + except (ClockError, OverflowError) as e: + raise NotificationError(f"min_interval: {e}") + if min_interval <= timedelta(0): + raise NotificationError("min_interval must be a positive duration") + with self._lock: + self._roles, self._max_active, self._min_interval = roles, max_active, min_interval + # a role left out loses its tools, so the checks it can no longer cancel go too + for check in self._checks.values(): + if check["status"] == "active" and check["to"] not in roles: + check["status"] = "cancelled" + self._prune() + return self.state() + + def clear(self) -> dict: + """Back to how a gateway starts: every setting at its default (checks off), no checks, no notifications.""" + with self._lock: + self._roles, self._max_active = (), DEFAULT_MAX_ACTIVE + self._min_interval = _parse_duration(DEFAULT_MIN_INTERVAL) + self._checks.clear() + self._queue.clear() + self._takes.clear() + return {"ok": True} + + @property + def _clock(self) -> Clock: + return self._clock_of() + + def _now(self) -> datetime: + now = self._clock.now() + if now is None: + raise NotificationError("this environment's clock is not set, so checks can't be scheduled") + return now + + def _next_id(self, prefix: str) -> str: + self._seq += 1 + return f"{prefix}-{self._seq}" + + # ---- agent tools ---- + + def schedule(self, note: Any, at: Any = None, every: Any = None, until: Any = None, *, + to: str = DEFAULT_ROLE) -> dict: + """A check for the role `to` that schedules it, refused for a role the config doesn't name.""" + if to not in self._roles: + raise NotificationError("scheduled checks are not available here") + if not isinstance(note, str) or not note.strip(): + raise NotificationError("note must say what to do when the check comes due") + if len(note) > _MAX_NOTE_CHARS: + raise NotificationError(f"note must be at most {_MAX_NOTE_CHARS} characters") + now = self._now() + step = None + if every is not None: + step = _duration(every, "every") + if step < self._min_interval: + raise NotificationError(f"every must be at least {_iso_duration(self._min_interval)} apart") + if at is None and step is None: + raise NotificationError("give at (when) or every (how often)") + first = _when(at, now, "at") if at is not None else _when(every, now, "every") + if step is not None: + try: + first + step # a repeat that can't be counted forward would break every later take + except OverflowError: + raise NotificationError("every is too long") + if first <= now: + raise NotificationError(f"at {_iso(first)} has already passed; it is now {_iso(now)}") + end = _when(until, now, "until") if until is not None else None + if end is not None and step is None: + raise NotificationError("until only applies with every") + if end is not None and end < first: + raise NotificationError("until is before the first check") + with self._lock: + if sum(c["status"] == "active" and c["to"] == to for c in self._checks.values()) >= self._max_active: + raise NotificationError(f"you already have {self._max_active} scheduled checks; cancel one first") + cid = self._next_id("check") + self._checks[cid] = {"check_id": cid, "to": to, "note": note, "next": first, "every": step, "until": end, + "created_at": now, "status": "active", "fired": 0} + # only what the gateway decided: the agent wrote the note, and the trajectory already logs it + return {k: v for k, v in self._public(self._checks[cid]).items() if k != "note"} + + def list(self, *, to: str = DEFAULT_ROLE) -> dict: + """The role's own checks still to come.""" + with self._lock: + return {"checks": [self._public(c) for c in self._checks.values() + if c["status"] == "active" and c["to"] == to]} + + def cancel(self, check_id: Any, *, to: str = DEFAULT_ROLE) -> dict: + """Cancel one of the role's own checks.""" + with self._lock: + check = self._checks.get(check_id) if isinstance(check_id, str) else None + if check is None or check["status"] != "active" or check["to"] != to: + raise NotificationError(f"no active scheduled check {check_id!r}") + check["status"] = "cancelled" + self._prune() + return {"cancelled": check_id} + + @staticmethod + def _public(check: dict) -> dict: + out = {"check_id": check["check_id"], "note": check["note"], "next": _iso(check["next"])} + if check["every"]: + out["every"] = _iso_duration(check["every"]) + if check["until"]: + out["until"] = _iso(check["until"]) + return out + + # ---- trigger action ---- + + def notify(self, channel: str, text: str, trigger_id: str | None = None, to: str = DEFAULT_ROLE) -> dict: + """Queue a notification for the role `to` at the current virtual time (none while the clock is unset: due at + once). Receiving one needs no tools, so `to` need not be among the config's roles.""" + now = self._clock.now() + with self._lock: + item = {"id": self._next_id("notification"), "to": to, "channel": channel, "text": text, + "trigger_id": trigger_id, "at": _iso(now) if now else None, "status": "pending", "_at": now} + self._queue.append(item) + return _public_note(item) + + # ---- harness ---- + + def state(self, to: Any = None) -> dict: + """The config, every check (with its recipient and status) and the pending notifications: those for the + recipient `to`, or for every recipient when it is None. `next_due_at` is the earliest of their active checks' + next time and pending notifications' time; one queued while the clock was unset is due now.""" + to = None if to is None else _recipient(to) + now = self._clock.now() + with self._lock: + checks = [c for c in self._checks.values() if to is None or c["to"] == to] + queued = [n for n in self._queue if n["status"] == "pending" and (to is None or n["to"] == to)] + times = [c["next"] for c in checks if c["status"] == "active"] + [n["_at"] or now for n in queued] + nxt = min((t for t in times if t is not None), default=None) + return {"roles": list(self._roles), "max_active": self._max_active, + "min_interval": _iso_duration(self._min_interval), "next_due_at": _iso(nxt) if nxt else None, + "checks": [{"check_id": c["check_id"], "to": c["to"], **self._public(c), "status": c["status"], + "fired": c["fired"]} for c in checks], + "pending_notifications": [_public_note(n) for n in queued]} + + def _prune(self) -> None: + """Drop delivered notifications and all but the last _HISTORY finished checks; the caller holds the lock.""" + finished = [cid for cid, c in self._checks.items() if c["status"] != "active"] + for cid in finished[:max(0, len(finished) - _HISTORY)]: + del self._checks[cid] + self._queue = [n for n in self._queue if n["status"] == "pending"] + + def take(self, body: Any = None) -> dict: + """Everything due for the recipient `to` (default: the solver's role) by `until` (default: now), oldest + first, as items of the one shape the module docstring gives; each is handed out once. A repeating check moves + to its next time after `until` and says how many times it came due (`times`), so a long jump wakes the agent + once. A `take_id` makes it safe to retry: the same id for the same recipient gets the same batch back instead + of an empty one.""" + body = body if isinstance(body, dict) else {} + to = _recipient(body.get("to", DEFAULT_ROLE)) + take_id = body.get("take_id") + if take_id is not None and (not isinstance(take_id, str) or not take_id or len(take_id) > _MAX_TAKE_ID_CHARS): + raise NotificationError(f"take_id must be a non-empty string of at most {_MAX_TAKE_ID_CHARS} characters") + if take_id is not None: + with self._lock: + seen = self._takes.get((to, take_id)) + if seen is not None: + return seen + now = self._clock.now() + until = _when(body["until"], now, "until") if body.get("until") is not None and now else now + due: list[tuple[datetime | None, dict]] = [] + with self._lock: + # work the whole batch out before marking anything handed out + checks = [(c, *_after(c, until)) for c in self._checks.values() + if c["status"] == "active" and c["to"] == to and until is not None and c["next"] <= until] + notes = [n for n in self._queue if n["status"] == "pending" and n["to"] == to + and (until is None or n["_at"] is None or n["_at"] <= until)] + for check, times, nxt in checks: + due.append((check["next"], _item(check["check_id"], to, _iso(check["next"]), times, CHECK_SOURCE, + check["check_id"], CHECK_CHANNEL, check["note"]))) + check["fired"] += 1 + if nxt is None: + check["status"] = "done" + else: + check["next"] = nxt + for note in notes: + note["status"] = "delivered" + due.append((note["_at"], _item(note["id"], to, note["at"], 1, TRIGGER_SOURCE, note["trigger_id"], + note["channel"], note["text"]))) + self._prune() + due.sort(key=lambda d: (d[0] is not None, d[0])) + result = {"to": to, "items": [item for _, item in due], "until": _iso(until) if until else None} + if take_id is not None: + self._takes[(to, take_id)] = result + while len(self._takes) > _KEPT_TAKES: + del self._takes[next(iter(self._takes))] + return result + + +def _item(item_id: str, to: str, at: str | None, times: int, source: str, source_id: str | None, channel: str, + text: str) -> dict: + """One thing `take` hands out, the same shape whatever its source.""" + return {"id": item_id, "to": to, "at": at, "times": times, "source": source, "source_id": source_id, + "channel": channel, "parts": [{"kind": "text", "text": text}]} + + +def _after(check: dict, until: datetime) -> tuple[int, datetime | None]: + """How many times a due check came due by `until`, and its next time after that (None: it is done). Counted, + not stepped, so a long jump over a short `every` costs nothing.""" + step, end = check["every"], check["until"] + if not step: + return 1, None + times = (until - check["next"]) // step + 1 + if end is not None: + times = min(times, (end - check["next"]) // step + 1) + try: + nxt = check["next"] + step * times + except OverflowError: + return times, None + return times, nxt if end is None or nxt <= end else None + + +def _iso_duration(td: timedelta) -> str: + """A duration in the ISO 8601 form the tools take (PT3H, P1DT6H, PT0.5S), so what they echo can be sent back.""" + hours, rest = divmod(td.seconds, 3600) + minutes, seconds = divmod(rest, 60) + secs = f"{seconds}.{td.microseconds:06d}".rstrip("0") if td.microseconds else (str(seconds) if seconds else "") + clock = (f"{hours}H" if hours else "") + (f"{minutes}M" if minutes else "") + (f"{secs}S" if secs else "") + return "P" + (f"{td.days}D" if td.days else "") + (f"T{clock}" if clock else "") if td.days or clock else "PT0S" + + +def _roles(value: Any) -> tuple[str, ...]: + if not isinstance(value, list): + raise NotificationError('roles must be a list of role names, e.g. ["default"]; [] turns scheduled checks off') + roles = tuple(dict.fromkeys(_recipient(r, "roles") for r in value)) + if WILDCARD in roles: + raise NotificationError(f"roles names each role; {WILDCARD!r} is not one") + return roles + + +def _recipient(value: Any, field: str = "to") -> str: + """A role name, as the AgentEnv-Role header gives it.""" + if not isinstance(value, str) or not value.strip() or len(value) > _MAX_ROLE_CHARS: + raise NotificationError(f"{field} must name a role (a non-empty string of at most {_MAX_ROLE_CHARS} " + "characters), e.g. 'default'") + return value.strip() + + +def _public_note(note: dict) -> dict: + return {k: v for k, v in note.items() if not k.startswith("_")} + + +def _tool_error(text: str) -> CallToolResult: + return CallToolResult(isError=True, content=[TextContent(type="text", text=text)]) + + +def build_check_tools(notifications: Notifications, make_arg_model: Callable[[str, dict], Any]) -> list[FastMCPTool]: + """The agent-facing tools, built by hand like get_time so a backing server's tool can't silently shadow them. Each + acts for the calling role (the AgentEnv-Role the call came with): its checks are its own.""" + + async def schedule_check(note: str, at: str | None = None, every: str | None = None, + until: str | None = None) -> dict | CallToolResult: + try: + return notifications.schedule(note, at=at, every=every, until=until, to=Caller.current().role) + except NotificationError as e: + return _tool_error(str(e)) + + async def list_scheduled_checks() -> dict: + return notifications.list(to=Caller.current().role) + + async def cancel_scheduled_check(check_id: str) -> dict | CallToolResult: + try: + return notifications.cancel(check_id, to=Caller.current().role) + except NotificationError as e: + return _tool_error(str(e)) + + specs = [ + (SCHEDULE_CHECK_TOOL_NAME, schedule_check, + SCHEDULE_CHECK_DESCRIPTION.format(max_active=notifications.max_active), + _schedule_schema(notifications.min_interval), False), + (LIST_SCHEDULED_CHECKS_TOOL_NAME, list_scheduled_checks, "Your scheduled checks that are still to come.", + _LIST_SCHEMA, True), + (CANCEL_SCHEDULED_CHECK_TOOL_NAME, cancel_scheduled_check, "Cancel one of your scheduled checks.", + _CANCEL_SCHEMA, False), + ] + return [FastMCPTool(fn=fn, name=name, description=description, parameters=schema, + fn_metadata=FuncMetadata(arg_model=make_arg_model(name, schema)), is_async=True, + annotations=ToolAnnotations(readOnlyHint=read_only)) + for name, fn, description, schema, read_only in specs] + + +def validate_recipient(to: Any, where: str) -> None: + """A notify action's `to`: the role the notification goes to.""" + try: + _recipient(to) + except NotificationError as e: + raise ValueError(f"{where}: {e}") + + +def validate_channel(channel: Any, where: str) -> None: + """A notify action's channel: the short source label the agent sees, e.g. 'Gmail' or 'Curbfinch alert'.""" + if not isinstance(channel, str) or not channel.strip(): + raise ValueError(f"{where}: channel must be a non-empty string, e.g. 'Gmail' or 'Curbfinch alert'") + if len(channel) > _MAX_CHANNEL_CHARS: + raise ValueError(f"{where}: channel must be at most {_MAX_CHANNEL_CHARS} characters") + if channel.strip().casefold() == CHECK_CHANNEL.casefold(): # a trigger can't pass itself off as the agent's own + raise ValueError(f"{where}: channel {CHECK_CHANNEL!r} is reserved for the agent's own scheduled checks; " + "name where the notification comes from, e.g. 'Gmail'") diff --git a/src/agent_env/env/gateway/triggers.py b/src/agent_env/env/gateway/triggers.py index 3b6da14c5..1017d3ef3 100644 --- a/src/agent_env/env/gateway/triggers.py +++ b/src/agent_env/env/gateway/triggers.py @@ -23,6 +23,7 @@ from mcp.types import CallToolResult, TextContent from .clock import ClockError, _advance, _iso, _parse_duration, _parse_rfc3339, _zone +from .notifications import validate_channel, validate_recipient from .caller import Caller from .constants import DEFAULT_ROLE, TRIGGER_IN_FLIGHT_STATUSES, TRIGGER_STATUSES @@ -34,7 +35,7 @@ _CTX_ROOTS = {"action": ("args", "result"), "website": ("args",)} # RFC 9110's safe methods: a request using one asks for no change, so it prompts no state re-evaluation. _SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "TRACE"}) -_ACTION_TYPES = ("nl", "tool", "permission") +_ACTION_TYPES = ("nl", "tool", "permission", "notify") _TERMINAL_A2A_STATES = ("completed", "failed", "canceled", "rejected") # Bounds the per-evaluation catch-up burst; the backlog drains on later evaluations. _MAX_CATCHUP = 1000 @@ -742,6 +743,10 @@ def _normalize_action(action: Any, w: str, roots: tuple[str, ...], executors: di raise TriggerError(f"{w}: 'as' is only valid on tool actions") if "executor" in action and atype != "nl": raise TriggerError(f"{w}: 'executor' is only valid on nl actions") + if "verify" in action and atype == "notify": + raise TriggerError(f"{w}: a notify action has nothing to verify") + if "to" in action and atype != "notify": + raise TriggerError(f"{w}: 'to' is only valid on notify actions") if action.get("delay") is None: action.pop("delay", None) else: @@ -761,6 +766,16 @@ def _normalize_action(action: Any, w: str, roots: tuple[str, ...], executors: di elif not isinstance(named, str) or named not in executors: raise TriggerError(f"{w}: executor {named!r} is not a registered executor name " f"(registered: {sorted(n for n in executors if n is not None)})") + elif atype == "notify": + try: + validate_channel(action.get("channel"), w) + if "to" in action: # the recipient role; the solver's (default) when absent + validate_recipient(action["to"], w) + except ValueError as e: + raise TriggerError(str(e)) + if not isinstance(action.get("text"), str) or not action["text"].strip(): + raise TriggerError(f"{w}: text must be the notification's non-empty text") + _validate_placeholders(action["text"], f"{w}.text", roots) elif atype == "tool": if not isinstance(action.get("tool"), str) or not action["tool"]: raise TriggerError(f"{w}: tool must be a non-empty string") @@ -1362,6 +1377,21 @@ async def _run_action(self, tid: str, trig: dict, index: int, action: dict, ctx: tool=action["tool"], args=_echo_args(args), **acting) return False detail = {"tool": action["tool"], "args": _echo_args(args), **acting} + elif action["type"] == "notify": + try: + # inside an object so a missing `${x?}` drops the text instead of sending the placeholder + text = str(_template({"text": action["text"]}, {}, ctx).get("text", "")) + except TemplateError as e: + self._emit("action_failed", tid, action_index=index, detail=str(e)[:300]) + return False + if not text.strip(): + detail = {"notify": action["channel"], "skipped": "text resolved to nothing"} + else: + note = self._gw._notifications.notify(action["channel"], text, trigger_id=tid, + to=action.get("to", DEFAULT_ROLE).strip()) + # the text may carry what a person wrote, so the log keeps its size, not its words + detail = {"notify": action["channel"], "to": note["to"], "notification_id": note["id"], + "text_len": len(text)} else: if not await self._run_nl(tid, trig, index, action, ctx): return False diff --git a/src/agent_env/examples/advance-to-next-event/README.md b/src/agent_env/examples/advance-to-next-event/README.md new file mode 100644 index 000000000..75708f4a1 --- /dev/null +++ b/src/agent_env/examples/advance-to-next-event/README.md @@ -0,0 +1,50 @@ +A prompt_agent post-turn hook that runs one conversation over simulated days and wakes the agent only for what +would really reach it. Unlike `next-env-event`, which wakes the agent with a nudge at every env event, it moves time +past world events silently. + +`artifacts/advance-to-next-event/hook.py` is the hook. After each agent turn it waits for what the turn set off to +finish, then lets a firing agent trigger answer. Otherwise it moves the frozen clock, silently, to the next thing +that happens: a world event (a trigger's mark, a delay ending, an executor's hours opening), something due for the +agent on notifications/v1, or a message the person writes at a set time. World events change the env but wake no +one. The agent is woken only when one of these has come due: + +- a check it scheduled with `schedule_check` (which a `configure_scheduled_checks` step gives its role), +- a notification a trigger sent it with a `notify` action, through a channel the task declares, +- a message from `person_messages`. + +The hook takes nothing from notifications/v1: it returns `{"message": []}` and `prompt_agent` delivers what came due, +each line saying when and through what, so nothing poses as the person. A person's message is the hook's own reply, +after those lines: + +``` +[Tue Feb 2, 9:00 AM UTC · Curbfinch alert] New for "rug 8x10": 1970s wool rug · $360 +[Tue Feb 2, 6:00 PM UTC · your scheduled check] Look for new 8x10 rugs under $300. +``` + +The conversation ends when nothing can wake the agent any more, or the next thing is past `end_time`. It also ends, +recorded as `triggers_still_firing`, if triggers are still running when `settle_timeout_seconds` runs out, rather +than treat unfinished work as nothing left to happen. Envs whose clock was never armed are left out. + +Reference the hook from a `prompt_agent` step, after `sync_env_clock` (frozen: `virtual_seconds_per_real_second: 0`) +and `configure_scheduled_checks` (`"roles": ["default"]`). Delivered lines, the person's included, are labelled in UTC: + +```json +{"id": "week", "type": "prompt_agent", "prompt": "Keep an eye on curbfinch this week for an 8x10 rug.", + "max_conversation_turns": 20, + "post_turn_hooks": [{"file_artifact_id": "@local/agentenv-framework/advance-to-next-event/advance-to-next-event", + "params": {"end_time": "P5D"}}]} +``` + +`tasks/week.json` runs it on `next-env-event`'s inbox: a school email that alerts the agent, a dentist email +that doesn't, and a message from the person midweek. + +Its params, all optional: + +- `end_time`: an RFC 3339 instant with an offset, or an ISO 8601 duration from the clock's t0 (`P5D`). +- `role`: the agent's AgentEnv-Role, whose checks and notifications it moves the clock to; `default` by default. +- `person_messages`: `[{"at": RFC 3339 instant, "text": "..."}]`, what the person writes and when. +- `settle_timeout_seconds`: how long to wait for triggers to settle before ending the run, 300 by default. +- `max_jumps_per_turn`: silent world events it may pass between two wakes, 500 by default. + +`max_conversation_turns` caps the wakes. The env needs a gateway with clock/v1 `advance` and notifications/v1. A hook +runs in the control-plane process with the trust of an `env_outcome_verifier` script. diff --git a/src/agent_env/examples/advance-to-next-event/artifacts/advance-to-next-event/hook.py b/src/agent_env/examples/advance-to-next-event/artifacts/advance-to-next-event/hook.py new file mode 100644 index 000000000..d9abe2f7d --- /dev/null +++ b/src/agent_env/examples/advance-to-next-event/artifacts/advance-to-next-event/hook.py @@ -0,0 +1,219 @@ +"""A prompt_agent post-turn hook that runs a conversation over simulated days by moving a frozen clock to the next +thing due for the agent: a check it scheduled for itself or a notification a trigger sent it (notifications/v1, which +prompt_agent delivers), or a message the person writes at a set time (which this hook sends). Everything else the +world does happens silently on the way, while the agent is away.""" +import asyncio +import logging +import re +import time +from datetime import datetime, timedelta, timezone + +from agent_env.task_step.task_steps.notification_delivery import label_time + +CLOCK = "urn:agentenv:clock/v1" +TRIGGERS = "urn:agentenv:triggers/v1" +NOTIFICATIONS = "urn:agentenv:notifications/v1" +IN_FLIGHT = ("firing", "queued") +PARAMS = ("end_time", "role", "person_messages", "settle_timeout_seconds", "max_jumps_per_turn") +POLL_SECONDS = 0.5 + +logger = logging.getLogger(__name__) + + +async def post_turn(turn, params): + """A firing agent trigger answers first; else move the clock, silently, to the next thing due for the agent.""" + if turn.last: + return None + end_time, role, person, settle_timeout, max_jumps = _params(params) + envs = await _armed(turn.envs) + if not envs: + return _end(turn, "no_armed_env_with_clock_and_notifications") + if (stuck := await _settle(turn, envs, settle_timeout)): + return _end(turn, "triggers_still_firing", envs=stuck) + answer = await turn.user_triggers() + if answer is not None: + return answer + t0 = min([_utc((await env.invoke(CLOCK, "state"))["t0"]) for env in envs]) + end = _end_instant(end_time, t0) + sent = _delivered_person(turn) + for jump in range(max_jumps + 1): + now = await _now(envs) + due = await _due(envs, role, now) + due_person = [m for m in person if m["at"] <= now and _iso(m["at"]) not in sent] + if due or due_person: + turn.record({"woke_at": _iso(now), "for": due + ["person"] * len(due_person), + **({"person_at": [_iso(m["at"]) for m in due_person]} if due_person else {})}) + # prompt_agent puts what came due on notifications/v1 ahead of this; [] sends only that + return {"message": _person_message(due_person) if due_person else []} + if jump == max_jumps: # checked above: what the last jump made due still wakes the agent + return _end(turn, "max_jumps_per_turn", at=_iso(now)) + nxt = await _next(envs, role, person, now, sent) + if nxt is None: + return _end(turn, "nothing_can_wake_the_agent", at=_iso(now)) + if end is not None and nxt > end: + return _end(turn, "end_time", next_at=_iso(nxt)) + for env in envs: + await _advance(env, nxt) + if (stuck := await _settle(turn, envs, settle_timeout)): + # ending here would read unfinished work as nothing left to happen, and lose what it sends + return _end(turn, "triggers_still_firing", envs=stuck, at=_iso(nxt)) + +def _params(params): + unknown = sorted(set(params) - set(PARAMS)) + if unknown: + raise ValueError(f"advance-to-next-event takes {list(PARAMS)}, not {unknown}") + end_time = params.get("end_time") + if end_time is not None: + _end_spec(end_time) + role = params.get("role", "default") + if not isinstance(role, str) or not role.strip(): + raise ValueError(f"role must name the agent's AgentEnv-Role, e.g. 'default', got {role!r}") + person = [] + for m in params.get("person_messages", []): + if not isinstance(m, dict) or not isinstance(m.get("at"), str) or not isinstance(m.get("text"), str): + raise ValueError("each person_messages entry is {at: RFC 3339 instant, text: what the person writes}") + person.append({"at": _utc(m["at"]), "text": m["text"]}) + timeout = params.get("settle_timeout_seconds", 300) + if isinstance(timeout, bool) or not isinstance(timeout, (int, float)) or timeout <= 0: + raise ValueError(f"settle_timeout_seconds must be a positive number, got {timeout!r}") + max_jumps = params.get("max_jumps_per_turn", 500) + if isinstance(max_jumps, bool) or not isinstance(max_jumps, int) or max_jumps < 1: + raise ValueError(f"max_jumps_per_turn must be a positive integer, got {max_jumps!r}") + return end_time, role.strip(), sorted(person, key=lambda m: m["at"]), timeout, max_jumps + + +async def _armed(envs): + """The envs with clock advance and notifications state whose clock is armed; an unarmed one has no time to move.""" + armed = [] + for env in envs: + if env.supports(CLOCK, "advance") and env.supports(NOTIFICATIONS, "state"): + if (await env.invoke(CLOCK, "state")).get("armed"): + armed.append(env) + return armed + + +async def _now(envs): + return min([_utc((await env.invoke(CLOCK, "state"))["virtual_time"]) for env in envs]) + + +async def _due(envs, role, now): + """The source of each check and notification due for ``role`` by now, oldest first. Read, not taken: prompt_agent + takes and delivers them.""" + due = [] + for env in envs: + state = await env.invoke(NOTIFICATIONS, "state", {"to": role}) + due += [(_utc(c["next"]), "scheduled_check") for c in state.get("checks", []) + if c["status"] == "active" and _utc(c["next"]) <= now] + due += [(_utc(n["at"]) if n.get("at") else now, "trigger") for n in state.get("pending_notifications", []) + if not n.get("at") or _utc(n["at"]) <= now] + return [source for _, source in sorted(due, key=lambda d: d[0])] + + +async def _next(envs, role, person, now, sent): + """The earliest instant after now at which anything happens: a world event (silent), a check or notification due + for ``role``, or a person's message.""" + candidates = [m["at"] for m in person if m["at"] > now and _iso(m["at"]) not in sent] + for env in envs: + notes = await env.invoke(NOTIFICATIONS, "state", {"to": role}) + if notes.get("next_due_at"): + candidates.append(_utc(notes["next_due_at"])) + if env.supports(TRIGGERS, "state"): + world = await env.invoke(TRIGGERS, "state") + if world.get("next_event_at"): + candidates.append(_utc(world["next_event_at"])) + later = [c for c in candidates if c > now] + return min(later) if later else None + + +def _delivered_person(turn): + """The person's messages this hook already delivered in this conversation, from its own records (a module global + would outlive the run, and another conversation's records are not this one's).""" + sent = set() + for entry in turn.records(): + sent.update(entry.get("person_at", [])) + return sent + + +def _person_message(person): + """One line per message, oldest first, each saying when it was written.""" + return "\n".join(f"[{label_time(_iso(m['at']))}] {m['text']}" for m in person) + + +async def _advance(env, target): + try: + await env.invoke(CLOCK, "advance", {"virtual_time": _iso(target)}) + except Exception: + clock = await env.invoke(CLOCK, "state") + if not (clock.get("armed") and _utc(clock["virtual_time"]) >= target): + raise + + +async def _settle(turn, envs, timeout): + """Wait until no trigger still fires (bar one waiting for a later time), so what an event set off has landed. + Returns the ids of the envs still firing when ``timeout`` ran out, empty when all settled.""" + deadline = time.monotonic() + timeout + waiting = {i for i, env in enumerate(envs) if env.supports(TRIGGERS, "state")} + while waiting and time.monotonic() < deadline: + for i in sorted(waiting): + env = envs[i] + clock, state = await env.invoke(CLOCK, "state"), await env.invoke(TRIGGERS, "state") + now = _utc(clock["virtual_time"]) + if state.get("next_event_at") is not None and _utc(state["next_event_at"]) <= now: + await _advance(env, now) + elif not any(_busy(row, now) for row in state["triggers"]): + waiting.discard(i) + if waiting: + turn.heartbeat() + await asyncio.sleep(POLL_SECONDS) + stuck = sorted(envs[i].env_id for i in waiting) + if stuck: + logger.warning(f"env(s) {stuck} still had triggers firing {timeout}s on") + return stuck + + +def _busy(row, now): + if row.get("evaluating"): # a state check the agent's turn set off is still running + return True + if row["status"] not in IN_FLIGHT and not row.get("pending"): + return False + return not (row.get("waiting") and row.get("resumes_at") and _utc(row["resumes_at"]) > now) + + +def _end_spec(end_time): + if isinstance(end_time, str) and end_time.startswith("P"): + return "after", _duration(end_time) + if isinstance(end_time, str): + at = _utc(end_time) + return "at", at + raise ValueError(f"end_time {end_time!r} is neither an RFC 3339 instant with an offset nor a duration like 'P4D'") + + +def _end_instant(end_time, t0): + if end_time is None: + return None + kind, value = _end_spec(end_time) + return value if kind == "at" else t0 + value + + +def _duration(text): + m = re.fullmatch(r"P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?", text) + if m is None or not any(m.groups()): + raise ValueError(f"invalid ISO 8601 duration {text!r}") + weeks, days, hours, minutes, seconds = (float(g or 0) for g in m.groups()) + return timedelta(weeks=weeks, days=days, hours=hours, minutes=minutes, seconds=seconds) + + +def _end(turn, reason, **detail): + turn.record({"end": reason, **detail}) + return {"end": reason} + + +def _utc(text): + at = datetime.fromisoformat(text.replace("Z", "+00:00")) + if at.tzinfo is None: + raise ValueError(f"{text!r} has no UTC offset") + return at.astimezone(timezone.utc) + + +def _iso(value): + return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") diff --git a/src/agent_env/examples/advance-to-next-event/tasks/week.json b/src/agent_env/examples/advance-to-next-event/tasks/week.json new file mode 100644 index 000000000..2bd80bd4a --- /dev/null +++ b/src/agent_env/examples/advance-to-next-event/tasks/week.json @@ -0,0 +1,24 @@ +[ + {"id": "env", "type": "deploy_env", "env_id": "@local/agentenv-framework/next-env-event/inbox", "sandbox_type": "local"}, + {"id": "clock", "type": "sync_env_clock", "env_id": "@local/agentenv-framework/next-env-event/inbox", + "virtual_time": "2026-03-02T08:00:00Z", "virtual_seconds_per_real_second": 0}, + {"id": "checks", "type": "configure_scheduled_checks", "env_id": "@local/agentenv-framework/next-env-event/inbox", + "roles": ["default"]}, + {"id": "mail", "type": "register_env_triggers", "env_id": "@local/agentenv-framework/next-env-event/inbox", + "watch_roles": ["default"], "triggers": [ + {"id": "school-pickup", "when": {"type": "time", "at": "2026-03-03T09:00:00Z"}, + "actions": [{"type": "tool", "tool": "deliver_email", "args": {"sender": "office@school.example", + "subject": "Thursday pick-up", "body": "Pick-up moves to 15:00 on Thursday. Please reply to confirm."}}, + {"type": "notify", "channel": "Mail", "text": "office@school.example: Thursday pick-up"}]}, + {"id": "dentist", "when": {"type": "time", "at": "P3DT6H"}, + "actions": [{"type": "tool", "tool": "deliver_email", "args": {"sender": "desk@dentist.example", + "subject": "Your appointment", "body": "Your check-up is moved to Friday at 10:00. Reply if that doesn't work."}}]} + ]}, + {"id": "agent", "type": "deploy_agent", "env_ids": ["@local/agentenv-framework/next-env-event/inbox"]}, + {"id": "week", "type": "prompt_agent", "max_conversation_turns": 8, + "prompt": "You look after my inbox this week. Mail from the school alerts you; nothing else does, so check the inbox yourself when it makes sense. Answer what needs an answer, and tell me in one line what you did.", + "post_turn_hooks": [{"file_artifact_id": "advance-to-next-event", + "params": {"end_time": "P5D", + "person_messages": [{"at": "2026-03-04T18:00:00Z", + "text": "Anything from the dentist yet?"}]}}]} +] diff --git a/src/agent_env/task/episode/rules.py b/src/agent_env/task/episode/rules.py index 1719ac289..33ad6fa38 100644 --- a/src/agent_env/task/episode/rules.py +++ b/src/agent_env/task/episode/rules.py @@ -147,8 +147,9 @@ def _rubrics_verifier(step: RubricsVerifierTaskStep, outside: Outside) -> list[s UNAFFECTED = frozenset({ # They name no agent. - "aggregate_verifiers", "apply_server_config", "build_mcp_cli", "combine_universe_verdicts", "deploy_env", - "deploy_sandbox", "end_computer_recording", "start_computer_recording", "env_outcome_verifier", "modify_env_tool_access", "reset_env", "review", + "aggregate_verifiers", "apply_server_config", "build_mcp_cli", "combine_universe_verdicts", "configure_scheduled_checks", + "deploy_env", "deploy_sandbox", "end_computer_recording", "start_computer_recording", "env_outcome_verifier", + "modify_env_tool_access", "reset_env", "review", "run_container_unit_tests_verifier", "run_docker_container", "snapshot_env", "sync_env_clock", "validation_gate_aggregator", "verify_env_card", "verify_env_core_protocol", "verify_mcp_tool_schema", "verify_spec_conformance", "verify_universe_load_export_roundtrip", diff --git a/src/agent_env/task_step/registry.py b/src/agent_env/task_step/registry.py index d0c32afca..3c74f4fcd 100644 --- a/src/agent_env/task_step/registry.py +++ b/src/agent_env/task_step/registry.py @@ -46,6 +46,7 @@ def _builtin_registry() -> dict[str, type["TaskStep"]]: from agent_env.task_step.task_steps.register_env_triggers import RegisterEnvTriggersStep from agent_env.task_step.task_steps.register_agent_triggers import RegisterAgentTriggersStep from agent_env.task_step.task_steps.sync_env_clock import SyncEnvClockTaskStep + from agent_env.task_step.task_steps.configure_scheduled_checks import ConfigureScheduledChecksTaskStep from agent_env.task_step.task_steps.install_agent import InstallAgentTaskStep from agent_env.task_step.task_steps.verifiers.rubrics_verifier import RubricsVerifierTaskStep from agent_env.task_step.task_steps.verifiers.run_container_unit_tests_verifier import RunContainerUnitTestsVerifierTaskStep @@ -100,6 +101,7 @@ def _builtin_registry() -> dict[str, type["TaskStep"]]: _get_type(RegisterEnvTriggersStep): RegisterEnvTriggersStep, _get_type(RegisterAgentTriggersStep): RegisterAgentTriggersStep, _get_type(SyncEnvClockTaskStep): SyncEnvClockTaskStep, + _get_type(ConfigureScheduledChecksTaskStep): ConfigureScheduledChecksTaskStep, _get_type(InstallAgentTaskStep): InstallAgentTaskStep, _get_type(RubricsVerifierTaskStep): RubricsVerifierTaskStep, _get_type(RunContainerUnitTestsVerifierTaskStep): RunContainerUnitTestsVerifierTaskStep, diff --git a/src/agent_env/task_step/task_steps/configure_scheduled_checks.py b/src/agent_env/task_step/task_steps/configure_scheduled_checks.py new file mode 100644 index 000000000..aa46f4f5c --- /dev/null +++ b/src/agent_env/task_step/task_steps/configure_scheduled_checks.py @@ -0,0 +1,94 @@ +"""Task step that turns on the checks the roles it names can schedule for themselves on a deployed env's gateway +(notifications/v1). + +With it each named role gets schedule_check, list_scheduled_checks and cancel_scheduled_check, on the env's virtual +clock; a check comes due for the role that scheduled it, and no other role sees the tools. Place it after +sync_env_clock (a check needs the clock) and before prompt_agent (the agent needs the tools on its first turn). +prompt_agent puts what has come due for its agent's role at the start of the agent's next message. It sends every +setting, since a config replaces them all. +""" + +from __future__ import annotations + +import logging +from typing import ClassVar, Optional + +import httpx + +from agent_env.env.gateway.constants import EXT_NOTIFICATIONS_URI +from agent_env.env.gateway.notifications import DEFAULT_MAX_ACTIVE, DEFAULT_MIN_INTERVAL +from agent_env.entity.refs import EntityRef +from agent_env.task_step.context import TaskStepContext +from agent_env.task_step.task_step import TaskStep, TaskStepDependency + +logger = logging.getLogger(__name__) + + +class ConfigureScheduledChecksTaskStep(TaskStep): + type: ClassVar[str] = "configure_scheduled_checks" + entity_refs = (EntityRef.env("env_id"),) + + def __init__( + self, + id: str, + version: Optional[int], + env_id: str, + roles: list[str], + max_active: int = DEFAULT_MAX_ACTIVE, + min_interval: str = DEFAULT_MIN_INTERVAL, + depends_on: Optional[list[TaskStepDependency]] = None, + fail_task_on_error: bool = True, + timeout_seconds: int = 30, + ): + """roles: the AgentEnv-Roles that get the tools ("default" is the solver's). max_active caps each role's checks + still to come; min_interval is the shortest `every` it may ask for.""" + super().__init__(id, version, depends_on=depends_on, fail_task_on_error=fail_task_on_error) + if (not isinstance(roles, list) or not roles + or not all(isinstance(r, str) and r.strip() for r in roles)): + raise ValueError('roles must name the roles that get the tools, e.g. ["default"] for the solver') + if isinstance(max_active, bool) or not isinstance(max_active, int) or max_active < 1: + raise ValueError("max_active must be a positive integer") + self.env_id = env_id + self.roles = roles + self.max_active = max_active + self.min_interval = min_interval + self.timeout_seconds = timeout_seconds + + def to_dict(self) -> dict: + base = super().to_dict() + base["env_id"] = self.env_id + base["roles"] = self.roles + base["max_active"] = self.max_active + base["min_interval"] = self.min_interval + base["timeout_seconds"] = self.timeout_seconds + return base + + @classmethod + def from_dict(cls, data: dict) -> ConfigureScheduledChecksTaskStep: + return cls( + **cls._base_from_dict(data), + env_id=data["env_id"], + roles=data.get("roles"), + max_active=data.get("max_active", DEFAULT_MAX_ACTIVE), + min_interval=data.get("min_interval", DEFAULT_MIN_INTERVAL), + timeout_seconds=data.get("timeout_seconds", 30), + ) + + async def execute(self, context: TaskStepContext) -> TaskStepContext: + deployed = next((d for d in context.deployed_envs if d.env_id == self.env_id), None) + if deployed is None: + raise RuntimeError(f"Env '{self.env_id}' not found in context.deployed_envs") + if not deployed.supports(EXT_NOTIFICATIONS_URI, "config"): + raise RuntimeError(f"Env '{self.env_id}' runs a gateway without {EXT_NOTIFICATIONS_URI}; redeploy it on a " + "gateway image that has it.") + body = {"roles": self.roles, "max_active": self.max_active, "min_interval": self.min_interval} + try: + state = await deployed.invoke(EXT_NOTIFICATIONS_URI, "config", body, timeout=self.timeout_seconds) + except httpx.HTTPStatusError as e: + raise RuntimeError(f"notifications config failed (HTTP {e.response.status_code}): {e.response.text}") from e + logger.info(f"configure_scheduled_checks completed for env={self.env_id} roles={self.roles}") + context.metadata.setdefault("scheduled_check_configurations", []).append({ + "step_id": self.id, "env_id": self.env_id, **body, + "applied": {k: state.get(k) for k in ("roles", "max_active", "min_interval")}, + }) + return context diff --git a/src/agent_env/task_step/task_steps/notification_delivery.py b/src/agent_env/task_step/task_steps/notification_delivery.py new file mode 100644 index 000000000..51d335f54 --- /dev/null +++ b/src/agent_env/task_step/task_steps/notification_delivery.py @@ -0,0 +1,117 @@ +"""How what came due on an env's notifications/v1 reaches an agent: the one place that says what a delivered line reads +like, for prompt_agent after each turn and for any post-turn hook that composes its own message. + +Every item ``take`` returns has one shape (``id``, ``to``, ``at``, ``times``, ``source``, ``source_id``, ``channel``, +``parts``), so each is delivered the same way, whatever its source: its text labelled with when and through what +(``channel``), so nothing poses as the person, and "came due N times" when ``times`` is more than one: + + [Mon Feb 1, 3:00 PM UTC · your scheduled check] See if Dana replied about the 2BR on Alder St + [Mon Feb 1, 3:10 PM UTC · Curbfinch alert] New listing: 2BR on Ivy St, $3,050/mo + +Times are in UTC, and say so, like everything else the agent reads about time (``get_time``, ``schedule_check``). + +Each delivered part carries ``metadata.agentenv``, the item without its parts (``source`` and ``source_id`` among +them), so a verifier reading the stored conversation can tell a delivered line from what the person wrote in the same +message. + +How prompt_agent delivers, after each agent turn: +- The user side answers first, as without notifications: a post-turn hook, else agent triggers, then the user sim or + a human. Delivery changes neither who is asked nor what they see; the user sim only receives the agent's replies. +- An answer that ends the conversation (the user sim's done, an agent trigger's end, a hook's end), the last turn + and a failed turn take nothing: what is due stays queued, and shows in notifications/v1 ``state``. +- Otherwise it takes what came due for its agent's role (``DeployedAgent.role``, else ``default``) on every env + whose card offers notifications/v1 ``take``, and the next message is those lines, then the reply as it was. +- A reply that says nothing (a hook's ``{"message": []}``) sends only the lines; with none due either, the + conversation ends (``nothing_to_send``). With nothing due, every message is what it was before. +- A hook that takes for itself leaves nothing here to repeat, since ``take`` hands each item out once. +""" +from __future__ import annotations + +import asyncio +import uuid +from datetime import datetime, timezone +from typing import Any, Iterable, Optional + +from agent_env.env.gateway.constants import EXT_NOTIFICATIONS_URI + +_TAKE_ATTEMPTS = 3 +_TAKE_RETRY_BASE_DELAY_S = 0.5 # seconds; the nth retry waits n times this +_EARLIEST = datetime.min.replace(tzinfo=timezone.utc) + + +def label_time(at: Optional[str]) -> str: + """'Mon Feb 1, 3:00 PM UTC'; 'now' for an item with no time (queued while the env clock was unset).""" + if not at: + return "now" + at = _utc(at) + return f"{at.strftime('%a %b')} {at.day}, {at.strftime('%I:%M %p').lstrip('0')} UTC" + + +def delivered_line(item: dict) -> str: + """A taken item's text as the agent reads it: ``[when · channel]``, then its text parts.""" + when = label_time(item.get("at")) + times = item.get("times", 1) + again = f" (it came due {times} times while you were away)" if times > 1 else "" + text = "\n\n".join(p["text"] for p in item["parts"] if p.get("kind") == "text") + return f"[{when} · {item['channel']}] {text}{again}" + + +def delivered_parts(items: Iterable[dict]) -> list[dict]: + """Per taken item, oldest first: one text part, its ``delivered_line``, then any parts that aren't text, as they + are. Each carries ``metadata.agentenv``: the item without its parts, with the ``env_id`` it came from when the + item has one.""" + parts = [] + for item in sorted(items, key=_oldest_first): + meta = {"agentenv": {k: v for k, v in item.items() if k != "parts"}} + parts.append({"kind": "text", "text": delivered_line(item), "metadata": meta}) + parts.extend({**p, "metadata": meta} for p in item["parts"] if p.get("kind") != "text") + return parts + + +def next_message(delivered: list[dict], reply: list[dict]) -> list[dict]: + """The agent's next message: what was delivered first, then the user side's reply as it is. A reply that says + nothing (no parts, or only blank text) adds nothing to delivered lines.""" + if delivered and says_nothing(reply): + return list(delivered) + return [*delivered, *reply] + + +def says_nothing(parts: list[dict]) -> bool: + return all(p.get("kind") == "text" and not str(p.get("text") or "").strip() for p in parts) + + +async def take_due(envs: Iterable[Any], to: str) -> list[dict]: + """Take what has come due for the role ``to`` from each env offering notifications/v1 ``take``, oldest first, + each item tagged with its ``env_id``. ``take`` hands each item out once, so a caller that takes first (a hook) + leaves nothing here to repeat. Each env's take carries one take_id across its retries, so a retry after a lost + response gets the same batch back. An env that keeps failing raises, after every env has been asked: the step + fails rather than carry on with a due item silently dropped.""" + envs = [env for env in envs if env.supports(EXT_NOTIFICATIONS_URI, "take")] + taken = await asyncio.gather(*(_take(env, to) for env in envs), return_exceptions=True) + failed = [(env, error) for env, error in zip(envs, taken) if isinstance(error, BaseException)] + if failed: + raise RuntimeError("; ".join(str(error) for _, error in failed)) from failed[0][1] + items = [{**item, "env_id": env.env_id} for env, answer in zip(envs, taken) for item in answer.get("items") or []] + return sorted(items, key=_oldest_first) + + +async def _take(env: Any, to: str) -> dict: + last: Optional[Exception] = None + body = {"to": to, "take_id": uuid.uuid4().hex} + for attempt in range(_TAKE_ATTEMPTS): + try: + return await env.invoke(EXT_NOTIFICATIONS_URI, "take", body) + except Exception as e: + last = e + if attempt + 1 < _TAKE_ATTEMPTS: + await asyncio.sleep(_TAKE_RETRY_BASE_DELAY_S * (attempt + 1)) + raise RuntimeError(f"could not take what came due for role '{to}' from env '{env.env_id}': {last}") + + +def _oldest_first(item: dict) -> tuple[bool, datetime]: + at = item.get("at") + return (at is not None, _utc(at) if at else _EARLIEST) + + +def _utc(text: str) -> datetime: + return datetime.fromisoformat(text.replace("Z", "+00:00")).astimezone(timezone.utc) diff --git a/src/agent_env/task_step/task_steps/post_turn_hooks.py b/src/agent_env/task_step/task_steps/post_turn_hooks.py index 6d1d69da5..4a04ab10d 100644 --- a/src/agent_env/task_step/task_steps/post_turn_hooks.py +++ b/src/agent_env/task_step/task_steps/post_turn_hooks.py @@ -24,6 +24,7 @@ from agent_env.env.gateway.constants import ( AGENT_ENV_ROLE_HEADER, EXT_CLOCK_URI, + EXT_NOTIFICATIONS_URI, EXT_STEP_URI, EXT_TRIGGERS_URI, STEP_SOURCE, @@ -33,7 +34,7 @@ _SPEC_KEYS = ("file_artifact_id", "file_artifact_version", "params") _RECORD_TAGS = ("hook", "turn") -_INVOKABLE = (EXT_CLOCK_URI, EXT_TRIGGERS_URI) +_INVOKABLE = (EXT_CLOCK_URI, EXT_TRIGGERS_URI, EXT_NOTIFICATIONS_URI) _TOOL_CALL_TIMEOUT_S = 600 Answer = dict[str, Any] @@ -118,7 +119,8 @@ def close(self) -> None: class HookEnv: - """A deployed env as a hook reaches it: clock and triggers calls, and tool calls recorded as the hook's.""" + """A deployed env as a hook reaches it: clock, triggers and notifications calls, and tool calls recorded as the + hook's.""" def __init__(self, deployed: DeployedEnv, hook_artifact_id: str) -> None: self.env_id = deployed.env_id @@ -216,30 +218,40 @@ def record(self, entry: dict) -> None: (self._context.metadata.setdefault("post_turn_hooks", {}).setdefault(self._step_id, []) .append({"hook": self._hook.artifact_id, "turn": self.number, **copy.deepcopy(entry)})) + def records(self) -> list[dict]: + """This hook's own records in this conversation, oldest first: what ``record`` appended for this step, as + read-only copies (another step's conversation, or another hook in this one, keeps its own).""" + entries = (self._context.metadata.get("post_turn_hooks") or {}).get(self._step_id, []) + return [copy.deepcopy(e) for e in entries if e.get("hook") == self._hook.artifact_id] + def heartbeat(self) -> None: self._context.heartbeat() def _decision(value: Any, hook: PostTurnHook) -> Optional[Answer]: - """A hook's return as an answer: ``{"message": parts}`` or ``{"end": reason}`` with an optional closing message.""" + """A hook's return as an answer: ``{"message": parts}`` or ``{"end": reason}`` with an optional closing message. + ``{"message": []}`` says nothing: the agent's next message is then only what came due for it on its envs' + notifications/v1, and the conversation ends (``nothing_to_send``) when nothing has.""" if value is None: return None - shape = "None, {'message': text or parts}, or {'end': reason} with an optional closing 'message'" + shape = "None, {'message': text or parts ([] for none)}, or {'end': reason} with an optional closing 'message'" if not isinstance(value, dict) or not value or set(value) - {"message", "end"}: raise RuntimeError(f"post-turn hook '{hook.artifact_id}' returned {value!r}; expected {shape}") if "end" in value and (not isinstance(value["end"], str) or not value["end"]): raise RuntimeError(f"post-turn hook '{hook.artifact_id}' ended with {value['end']!r}; the reason is a string") answer: Answer = {"end": value["end"]} if "end" in value else {} if "message" in value: - answer["message"] = _parts(value["message"], hook) + parts = _parts(value["message"], hook) + if parts or "end" not in answer: # an empty closing message records nothing + answer["message"] = parts return answer def _parts(message: Any, hook: PostTurnHook) -> list[dict]: if isinstance(message, str): return [{"kind": "text", "text": message}] - if not isinstance(message, list) or not message: - raise RuntimeError(f"post-turn hook '{hook.artifact_id}': a message is text or a non-empty list of A2A parts") + if not isinstance(message, list): + raise RuntimeError(f"post-turn hook '{hook.artifact_id}': a message is text or a list of A2A parts") for i, part in enumerate(message): try: Part.model_validate(part) diff --git a/src/agent_env/task_step/task_steps/prompt_agent.py b/src/agent_env/task_step/task_steps/prompt_agent.py index 13b2e6d76..145fd5e2d 100644 --- a/src/agent_env/task_step/task_steps/prompt_agent.py +++ b/src/agent_env/task_step/task_steps/prompt_agent.py @@ -26,13 +26,20 @@ from agent_env.a2a_agent.staging import draining, staged_changelogs, transfer_store from agent_env.a2a_agent.trajectory_follower import live_trajectory_endpoint from agent_env.config.model import MODEL_PARAMS_RESERVED -from agent_env.env.gateway.constants import EXT_CLOCK_URI, EXT_TRIGGERS_URI, TRIGGER_IN_FLIGHT_STATUSES +from agent_env.env.gateway.constants import ( + DEFAULT_ROLE, + EXT_CLOCK_URI, + EXT_NOTIFICATIONS_URI, + EXT_TRIGGERS_URI, + TRIGGER_IN_FLIGHT_STATUSES, +) from agent_env.store import DuplicateKeyError, get_config from agent_env.store.object_store import ObjectStore from agent_env.task_step.context import DeployedAgent, PromptResponse, TaskStepContext from agent_env.entity.refs import EntityRef from agent_env.entity.scopes import FieldScope from agent_env.task_step.task_step import RetryConfig, TaskStep, TaskStepDependency +from agent_env.task_step.task_steps.notification_delivery import delivered_parts, next_message, take_due from agent_env.task_step.task_steps.post_turn_hooks import PostTurnHooks, run_post_turn_hooks, validate_hook_specs from agent_env.task_step.task_steps.sandbox_utils.sandbox_utils import ( agent_error_text, @@ -541,6 +548,9 @@ async def _converse( current_user_parts = initial_parts clock_envs = [d for d in context.deployed_envs if d.supports(EXT_CLOCK_URI, "state")] + # What comes due for the agent's role on these envs (notifications/v1) opens its next message. + notification_envs = [d for d in context.deployed_envs if d.supports(EXT_NOTIFICATIONS_URI, "take")] + recipient = agent.role or DEFAULT_ROLE final_terminal = protocol.TerminalResponse(response_text="") # Each turn's terminal reports only that turn's tool calls, so the conversation's count is # their sum. None until a turn reports one, so agents that don't report it stay None. @@ -669,7 +679,14 @@ def record_turn() -> None: break if last: break - current_user_parts = answer["message"] + # The conversation goes on: what came due for the agent goes first, the user side's reply after it. + # An answer that ended it, or the last turn, takes nothing, so what is due stays queued on the env. + due = await take_due(notification_envs, recipient) if notification_envs else [] + current_user_parts = next_message(delivered_parts(due), answer["message"]) + if not current_user_parts: # a hook said nothing ([]) and nothing came due + logger.info(f"Turn {turn+1}: nothing to send the agent; ending multi-turn (nothing_to_send)") + conversation_store.mark_closed(conversation_id) + break finally: # On failure too, so a reader of the last turn knows no other follows. if live_turns is not None: diff --git a/src/agent_env/task_step/task_steps/register_env_triggers.py b/src/agent_env/task_step/task_steps/register_env_triggers.py index cf651595a..156d17fb9 100644 --- a/src/agent_env/task_step/task_steps/register_env_triggers.py +++ b/src/agent_env/task_step/task_steps/register_env_triggers.py @@ -17,6 +17,7 @@ TRIGGER_EXECUTOR_HOURS, TRIGGER_EXECUTOR_HOURS_TIME_ZONE, TRIGGER_NAMED_EXECUTORS, + TRIGGER_NOTIFY, TRIGGER_PROVOKING_ROLES, TRIGGER_UNLESS, TRIGGER_WEBSITE, @@ -151,7 +152,9 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: TRIGGER_UNLESS: _when_has(self.triggers, "unless"), TRIGGER_WEBSITE: any(isinstance(t.get("when"), dict) and t["when"].get("type") == "website" for t in self.triggers), - TRIGGER_EXECUTOR_HOURS_TIME_ZONE: _hours_in_a_time_zone(self.executor_hours)} + TRIGGER_EXECUTOR_HOURS_TIME_ZONE: _hours_in_a_time_zone(self.executor_hours), + TRIGGER_NOTIFY: any(a.get("type") == "notify" for t in self.triggers + for a in t.get("actions") or [] if isinstance(a, dict))} missing = [feature for feature, needed in used.items() if needed and feature not in _trigger_features(deployed)] if missing: raise RuntimeError( diff --git a/tst/installer/test_container_journey.py b/tst/installer/test_container_journey.py index 477460382..94d0edfc4 100644 --- a/tst/installer/test_container_journey.py +++ b/tst/installer/test_container_journey.py @@ -133,7 +133,7 @@ def test_a_new_user_installs_agent_env_adds_plugins_runs_a_task_and_removes_them container.ok("python", "-m", "pip", "install", "-q", "--no-index", "--find-links", "/wheels", "uv") container.ok("uv", "tool", "install", "agentenv-framework") rows = container.ok("agent-env", "plugin", "list").stdout.split("STATUS\n", 1)[1] - assert re.fullmatch(r"agentenv-framework +\S+ +8 bundles +ok\n", rows), rows + assert re.fullmatch(r"agentenv-framework +\S+ +9 bundles +ok\n", rows), rows added = container.ok("agent-env", "plugin", "add", browser, grader, "--yes") assert "toy_navigate active" in added.stdout and "toy_grade active" in added.stdout diff --git a/tst/integration/task_step/test_advance_to_next_event_hook_local.py b/tst/integration/task_step/test_advance_to_next_event_hook_local.py new file mode 100644 index 000000000..cbfed382d --- /dev/null +++ b/tst/integration/task_step/test_advance_to_next_event_hook_local.py @@ -0,0 +1,82 @@ +"""The advance-to-next-event example hook on a real gateway: a world event lands silently, and the hook moves the clock only +to the agent's own check and a notify action's notification, which prompt_agent delivers, each labelled with its time +and source.""" + +import json +import uuid +from pathlib import Path + +import httpx +import pytest + +import agent_env +from agent_env.a2a_agent import conversation_store +from agent_env.artifact import FileArtifact +from agent_env.env.gateway import AGENT_ENV_ROLE_HEADER +from agent_env.task_step.context import TaskStepContext +from agent_env.task_step.task_steps.configure_scheduled_checks import ConfigureScheduledChecksTaskStep +from agent_env.task_step.task_steps.prompt_agent import PromptAgentTaskStep +from agent_env.task_step.task_steps.register_env_triggers import RegisterEnvTriggersStep +from agent_env.task_step.task_steps.sync_env_clock import SyncEnvClockTaskStep +from tst.integration.store.local_parallel_deploy_test import _seed_bootstrap_envs +from tst.integration.task_step.test_prompt_agent_live_trajectory_local import _deploy, local_registry as local_registry +from tst.integration.task_step.test_register_env_triggers_local import _deploy_items +from tst.util.a2a_test_agent import put_test_agent + +pytestmark = [pytest.mark.integration, pytest.mark.int_test_slow] + +HOOK = Path(agent_env.__file__).parent / "examples" / "advance-to-next-event" / "artifacts" / "advance-to-next-event" / "hook.py" +MONDAY = "2026-03-02T13:00:00Z" + + +@pytest.mark.asyncio +async def test_only_the_agent_s_check_and_a_declared_notification_wake_it(local_registry): + _seed_bootstrap_envs() + env, deployed, _ = await _deploy_items("wakeups") + agent = put_test_agent(f"wakeups-agent-{uuid.uuid4().hex[:8]}") + hook = FileArtifact.put(f"wakeups-hook-{uuid.uuid4().hex[:8]}", description="advance-to-next-event", file_path=str(HOOK)) + context = TaskStepContext(deployed_envs=[deployed], deployed_agents=[], metadata={}) + triggers = [ + {"id": "w-1", "when": {"type": "time", "at": "2026-03-03T17:00:00Z"}, + "actions": [{"type": "tool", "tool": "items_add_item", "args": {"item": "rug listed"}}]}, + {"id": "w-2", "when": {"type": "time", "at": "2026-03-04T16:00:00Z"}, + "actions": [{"type": "notify", "channel": "Messages", "text": "Jamie: did the rug thing get sorted?"}]}, + ] + try: + await SyncEnvClockTaskStep(id="clock", version=None, env_id=env.id, virtual_time=MONDAY, + virtual_seconds_per_real_second=0).execute(context) + await ConfigureScheduledChecksTaskStep(id="checks", version=None, env_id=env.id, + roles=["default"]).execute(context) + await RegisterEnvTriggersStep(id="world", version=None, env_id=env.id, triggers=triggers, + watch_roles=["default"]).execute(context) + async with httpx.AsyncClient(base_url=deployed.gateway_url, timeout=60) as client: + # what the agent would do in its first turn: schedule a check of its own, through the tool + made = await client.post("/step", headers={AGENT_ENV_ROLE_HEADER: "default"}, json={ + "action": "call_tool", "tool_name": "schedule_check", + "arguments": {"note": "look for new rugs", "at": "2026-03-04T02:00:00Z"}}) + assert made.status_code == 200 and not made.json().get("isError"), made.text + await _deploy(agent, "solver").execute(context) + await PromptAgentTaskStep( + id="watch", version=None, prompt="Keep an eye on things.", agent_name="solver", max_conversation_turns=6, + poll_interval_seconds=1, + post_turn_hooks=[{"file_artifact_id": hook.id, "file_artifact_version": hook.version, + "params": {"end_time": "P5D"}}], + ).execute(context) + async with httpx.AsyncClient(base_url=deployed.gateway_url, timeout=60) as client: + stored = await client.post("/step", json={"action": "call_tool", "tool_name": "list_items", "arguments": {}}, + headers={AGENT_ENV_ROLE_HEADER: "harness"}) + state = (await client.get("/notifications/state")).raise_for_status().json() + finally: + await env.close() + + messages = conversation_store.get_conversation(context.metadata["a2a_conversations"]["watch"])["messages"] + assert [[p["text"] for p in m["parts"]] for m in messages if m["role"] == "user"] == [ + ["Keep an eye on things."], + ["[Wed Mar 4, 2:00 AM UTC · your scheduled check] look for new rugs"], + ["[Wed Mar 4, 4:00 PM UTC · Messages] Jamie: did the rug thing get sorted?"], + ] + assert "rug listed" in json.loads(stored.json()["content"][0]["text"])["items"] + assert state["pending_notifications"] == [] and state["next_due_at"] is None + records = context.metadata["post_turn_hooks"]["watch"] + assert [r.get("for") for r in records] == [["scheduled_check"], ["trigger"], None] + assert records[-1]["end"] == "nothing_can_wake_the_agent" diff --git a/tst/integration/task_step/test_prompt_agent_notifications_local.py b/tst/integration/task_step/test_prompt_agent_notifications_local.py new file mode 100644 index 000000000..324c78b0a --- /dev/null +++ b/tst/integration/task_step/test_prompt_agent_notifications_local.py @@ -0,0 +1,87 @@ +"""prompt_agent delivers notifications/v1 on a real gateway: the solver's own check and a trigger's notify open the +agent's next message, ahead of what the next-env-event hook says, while a role the config doesn't name can't +schedule at all.""" + +import uuid +from pathlib import Path + +import httpx +import pytest + +import agent_env +from agent_env.a2a_agent import conversation_store +from agent_env.artifact import FileArtifact +from agent_env.env.gateway import AGENT_ENV_ROLE_HEADER +from agent_env.task_step.context import TaskStepContext +from agent_env.task_step.task_steps.configure_scheduled_checks import ConfigureScheduledChecksTaskStep +from agent_env.task_step.task_steps.prompt_agent import PromptAgentTaskStep +from agent_env.task_step.task_steps.register_env_triggers import RegisterEnvTriggersStep +from agent_env.task_step.task_steps.sync_env_clock import SyncEnvClockTaskStep +from tst.integration.store.local_parallel_deploy_test import _seed_bootstrap_envs +from tst.integration.task_step.test_prompt_agent_live_trajectory_local import _deploy, local_registry as local_registry +from tst.integration.task_step.test_register_env_triggers_local import _deploy_items +from tst.util.a2a_test_agent import put_test_agent + +pytestmark = [pytest.mark.integration, pytest.mark.int_test_slow] + +HOOK = Path(agent_env.__file__).parent / "examples" / "next-env-event" / "artifacts" / "next-env-event" / "hook.py" +MONDAY = "2026-03-02T13:00:00Z" +TUESDAY = "2026-03-03T16:40:00Z" # 8:40 AM in Los Angeles +CHECK_AT = "2026-03-03T15:40:00Z" # 7:40 AM +NUDGE = "[{}] Time has passed. Check for anything new and handle it." + + +def _schedule(note: str, at: str) -> dict: + return {"action": "call_tool", "tool_name": "schedule_check", "arguments": {"note": note, "at": at}} + + +@pytest.mark.asyncio +async def test_what_came_due_for_the_solver_opens_its_next_message_on_a_real_gateway(local_registry): + _seed_bootstrap_envs() + env, deployed, _ = await _deploy_items("notify") + agent = put_test_agent(f"notify-agent-{uuid.uuid4().hex[:8]}") + hook = FileArtifact.put(f"notify-hook-{uuid.uuid4().hex[:8]}", description="next-env-event", file_path=str(HOOK)) + context = TaskStepContext(deployed_envs=[deployed], deployed_agents=[], metadata={}) + triggers = [{"id": "ivy-listed", "when": {"type": "time", "at": TUESDAY}, + "actions": [{"type": "notify", "channel": "Curbfinch alert", "text": "New listing: 2BR on Ivy St"}]}] + try: + await SyncEnvClockTaskStep(id="clock", version=None, env_id=env.id, virtual_time=MONDAY, + virtual_seconds_per_real_second=0).execute(context) + await ConfigureScheduledChecksTaskStep(id="checks", version=None, env_id=env.id, + roles=["default"]).execute(context) + await RegisterEnvTriggersStep(id="world", version=None, env_id=env.id, triggers=triggers, + watch_roles=["default"]).execute(context) + async with httpx.AsyncClient(base_url=deployed.gateway_url, timeout=60) as client: + # what the agent would do in its first turn: schedule a check of its own, through the tool + made = await client.post("/step", json=_schedule("look for new rugs", CHECK_AT), + headers={AGENT_ENV_ROLE_HEADER: "default"}) + assert made.status_code == 200 and not made.json().get("isError"), made.text + refused = await client.post("/step", json=_schedule("snoop", CHECK_AT), + headers={AGENT_ENV_ROLE_HEADER: "landlord"}) + assert refused.status_code == 403, refused.text + await _deploy(agent, "solver").execute(context) + await PromptAgentTaskStep( + id="watch", version=None, prompt="Keep an eye on things.", agent_name="solver", max_conversation_turns=4, + poll_interval_seconds=1, + post_turn_hooks=[{"file_artifact_id": hook.id, "file_artifact_version": hook.version, + "params": {"end_time": "P5D"}}], + ).execute(context) + async with httpx.AsyncClient(base_url=deployed.gateway_url, timeout=60) as client: + state = (await client.get("/notifications/state")).raise_for_status().json() + finally: + await env.close() + + messages = conversation_store.get_conversation(context.metadata["a2a_conversations"]["watch"])["messages"] + sent = [m["parts"] for m in messages if m["role"] == "user"] + assert [[p["text"] for p in parts] for parts in sent] == [ + ["Keep an eye on things."], + ["[Tue Mar 3, 3:40 PM UTC · your scheduled check] look for new rugs", + "[Tue Mar 3, 4:40 PM UTC · Curbfinch alert] New listing: 2BR on Ivy St", + NUDGE.format(TUESDAY)], + ] + assert [p.get("metadata", {}).get("agentenv", {}).get("source") for p in sent[1]] == [ + "scheduled_check", "trigger", None] + assert sent[1][1]["metadata"]["agentenv"]["source_id"] == "ivy-listed" + assert state["pending_notifications"] == [] and state["next_due_at"] is None + assert [(c["to"], c["status"]) for c in state["checks"]] == [("default", "done")] + assert context.metadata["post_turn_hooks"]["watch"][-1]["end"] == "no_pending_events" diff --git a/tst/unit/bundle/installed_test.py b/tst/unit/bundle/installed_test.py index be77271c8..677bdc9c9 100644 --- a/tst/unit/bundle/installed_test.py +++ b/tst/unit/bundle/installed_test.py @@ -127,8 +127,8 @@ def test_the_plugin_report_counts_the_bundles_agent_env_ships(monkeypatch, quiet assert result.exit_code == 0, result.output (core,) = json.loads(result.stdout)["plugins"] assert [(c["group"], c["name"], c["status"]) for c in core["contributions"]] == [ - (BUNDLES, "agents", "active"), (BUNDLES, "computers", "active"), (BUNDLES, "desktop", "active"), - (BUNDLES, "drivers", "active"), + (BUNDLES, "advance-to-next-event", "active"), (BUNDLES, "agents", "active"), (BUNDLES, "computers", "active"), + (BUNDLES, "desktop", "active"), (BUNDLES, "drivers", "active"), (BUNDLES, "echo-mcp", "active"), (BUNDLES, "hello", "active"), (BUNDLES, "mail-mcp", "active"), (BUNDLES, "next-env-event", "active")] assert "\n agent-env itself\n" in shown and "requires:" not in shown diff --git a/tst/unit/bundle/shipped_bundles_test.py b/tst/unit/bundle/shipped_bundles_test.py index 070250e5b..42891a4b3 100644 --- a/tst/unit/bundle/shipped_bundles_test.py +++ b/tst/unit/bundle/shipped_bundles_test.py @@ -26,7 +26,8 @@ def _shipped() -> dict: def test_agentenv_framework_ships_its_bundles(): - assert SHIPPED == ["agents", "computers", "desktop", "drivers", "echo-mcp", "hello", "mail-mcp", "next-env-event"] + assert SHIPPED == ["advance-to-next-event", "agents", "computers", "desktop", "drivers", "echo-mcp", "hello", "mail-mcp", + "next-env-event"] @pytest.mark.parametrize("name", SHIPPED) diff --git a/tst/unit/env/gateway/gateway_card_test.py b/tst/unit/env/gateway/gateway_card_test.py index 05b06ce69..6b71a6b7c 100644 --- a/tst/unit/env/gateway/gateway_card_test.py +++ b/tst/unit/env/gateway/gateway_card_test.py @@ -201,12 +201,12 @@ def test_rewrite_child_card_drops_the_childs_interfaces(): assert _gateway({"mcp-slack": "http://slack:18765"})._rewrite_child_card("mcp-slack", child)["additionalInterfaces"] == [] -def test_gateway_card_lists_its_eight_extensions(): +def test_gateway_card_lists_its_nine_extensions(): assert _gateway({})._gateway_extensions() is gateway_constants.GATEWAY_EXTENSIONS assert [e["uri"] for e in _gateway({})._gateway_extensions()] == [ "urn:agentenv:disable-tool/v1", "urn:agentenv:enable-tool/v1", "urn:agentenv:triggers/v1", "urn:agentenv:clock/v1", "urn:agentenv:trajectory/v1", "urn:agentenv:step/v1", "urn:agentenv:state/v1", - "urn:agentenv:changes/v1", + "urn:agentenv:changes/v1", "urn:agentenv:notifications/v1", ] diff --git a/tst/unit/env/gateway/notifications_test.py b/tst/unit/env/gateway/notifications_test.py new file mode 100644 index 000000000..afb30743c --- /dev/null +++ b/tst/unit/env/gateway/notifications_test.py @@ -0,0 +1,663 @@ +"""Unit tests for an agent's notifications (urn:agentenv:notifications/v1): scheduled checks and the notify action.""" +from __future__ import annotations + +import asyncio +import json + +import httpx +import pytest +from mcp.types import CallToolResult, TextContent +from mcp.types import Tool as MCPTool + +from agent_env.env.gateway import gateway as gw_mod +from agent_env.env.gateway.caller import Caller +from agent_env.env.gateway.clock import Clock +from agent_env.env.gateway.constants import AGENT_ENV_ROLE_HEADER, GATEWAY_EXTENSIONS +from agent_env.env.gateway.gateway import Gateway +from agent_env.env.gateway.triggers import TriggerEngine, TriggerError +from agent_env.env.gateway.notifications import CHECK_TOOL_NAMES, NotificationError, Notifications + +T0 = "2027-02-01T15:00:00Z" # Mon 7:00 AM Pacific + + +class FakeNow: + def __init__(self, start: float = 1000.0): + self.t = start + + def __call__(self) -> float: + return self.t + + def tick(self, seconds: float) -> None: + self.t += seconds + + +def _frozen_clock(at: str = T0) -> Clock: + clock = Clock(now_fn=FakeNow()) + clock.set_time(at, 0) + return clock + + +def _notifications(clock: Clock | None = None, **config) -> Notifications: + clock = clock or _frozen_clock() + w = Notifications(lambda: clock) + w.configure({"roles": ["default"], **config}) + return w + + +def _said(item: dict) -> str: + """A taken item's text.""" + [part] = item["parts"] + return part["text"] + + +# ---- Notifications ---- + +def test_scheduled_checks_are_off_until_a_task_turns_them_on(): + w = Notifications(lambda: _frozen_clock()) + assert w.state()["roles"] == [] + with pytest.raises(NotificationError, match="not available"): + w.schedule("look again", at="PT6H") + + +def test_a_check_takes_a_duration_from_now_or_a_time_with_its_offset(): + w = _notifications() + assert w.schedule("in six hours", at="PT6H")["next"] == "2027-02-01T21:00:00Z" + assert w.schedule("tonight", at="2027-02-01T18:00:00-08:00")["next"] == "2027-02-02T02:00:00Z" + assert [c["note"] for c in w.list()["checks"]] == ["in six hours", "tonight"] + + +def test_schedule_returns_what_the_gateway_decided_and_list_still_shows_the_note(): + n = _notifications() + made = n.schedule("Check the saved search for 2BRs on Alder St", at="PT2H", every="P1D", until="P4D") + assert made == {"check_id": "check-1", "next": "2027-02-01T17:00:00Z", "every": "P1D", + "until": "2027-02-05T15:00:00Z"} + assert n.schedule("once", at="PT3H") == {"check_id": "check-2", "next": "2027-02-01T18:00:00Z"} + assert [c["note"] for c in n.list()["checks"]] == ["Check the saved search for 2BRs on Alder St", "once"] + assert [c["note"] for c in n.state()["checks"]] == ["Check the saved search for 2BRs on Alder St", "once"] + + +@pytest.mark.parametrize("kwargs, error", [ + ({"note": "x"}, "give at"), + ({"note": " ", "at": "PT1H"}, "note must say"), + ({"note": "x", "at": "2027-02-01T06:00:00-08:00"}, "already passed"), + ({"note": "x", "at": "2027-02-01T18:00:00"}, "at must be a time with seconds and its UTC offset"), + ({"note": "x", "every": "PT10M"}, "at least"), + ({"note": "x", "at": "PT1H", "until": "P1D"}, "until only applies"), + ({"note": "x", "every": "PT6H", "until": "PT1H"}, "before the first"), + ({"note": "x", "at": "PT1H", "every": "P3000000D"}, "too long"), + ({"note": "x", "at": "P3000000D"}, "too far away"), +]) +def test_bad_checks_are_refused_with_the_reason(kwargs, error): + with pytest.raises(NotificationError, match=error): + _notifications().schedule(**kwargs) + + +@pytest.mark.parametrize("kwargs, error", [ + ({"at": "2027-02-02T18:00-08:00"}, "at must be a time with seconds and its UTC offset"), + ({"at": "tomorrow"}, "at must be a time with seconds"), + ({"at": "pt6h"}, "at must be a duration in weeks, days, hours, minutes or seconds"), + ({"every": "P1M"}, "every must be a duration .*no months or years"), + ({"every": "P1Y"}, "every must be a duration"), + ({"every": "PT1.5H"}, "every must be a duration .*only seconds may have a fraction"), + ({"every": "P9999999999D"}, "every is too long"), + ({"at": "PT1H", "every": "PT6H", "until": "next week"}, "until must be a time with seconds"), +]) +def test_a_bad_time_s_error_names_the_argument_the_agent_gave_and_the_rule(kwargs, error): + with pytest.raises(NotificationError, match=error) as refused: + _notifications().schedule("x", **kwargs) + assert "virtual_time" not in str(refused.value) and "RFC" not in str(refused.value) + + +@pytest.mark.parametrize("text", ["PT3H", "P1D", "P1DT6H30M", "PT0.001S", "PT1M0.5S"]) +def test_durations_are_echoed_in_the_form_the_tools_take(text): + w = _notifications(min_interval="PT0.001S") + assert w.schedule("x", every=text)["every"] == text + assert _notifications(min_interval=text).state()["min_interval"] == text + + +def test_the_too_short_error_names_the_minimum_as_a_duration_to_send_back(): + with pytest.raises(NotificationError, match=r"at least PT3H apart"): + _notifications(min_interval="PT3H").schedule("x", every="PT1H") + + +def test_the_active_cap_refuses_one_more_until_one_is_cancelled(): + w = _notifications(max_active=2) + first = w.schedule("a", at="PT1H")["check_id"] + w.schedule("b", at="PT2H") + with pytest.raises(NotificationError, match="already have 2"): + w.schedule("c", at="PT3H") + w.cancel(first) + w.schedule("c", at="PT3H") + with pytest.raises(NotificationError, match="no active scheduled check"): + w.cancel(first) + + +def test_take_hands_out_each_due_check_once_and_a_one_shot_is_done(): + clock = _frozen_clock() + w = _notifications(clock) + w.schedule("look at 9", at="PT2H") + w.schedule("look at noon", at="PT5H") + assert w.state()["next_due_at"] == "2027-02-01T17:00:00Z" + clock.set_time("2027-02-01T17:00:00Z", 0) + taken = w.take({})["items"] + assert [(i["source"], _said(i), i["at"]) for i in taken] == [("scheduled_check", "look at 9", "2027-02-01T17:00:00Z")] + assert w.take({})["items"] == [] + assert [c["note"] for c in w.list()["checks"]] == ["look at noon"] + assert w.state()["next_due_at"] == "2027-02-01T20:00:00Z" + + +def test_next_due_at_is_the_earliest_of_the_checks_and_the_pending_notifications(): + clock = _frozen_clock() + n = _notifications(clock) + n.schedule("look again", at="PT6H") + clock.set_time("2027-02-01T16:00:00Z", 0) + n.notify("Messages", "Jamie: still on for tonight?") # queued at 16:00, before the check at 21:00 + assert n.state()["next_due_at"] == "2027-02-01T16:00:00Z" + n.take({}) + assert n.state()["next_due_at"] == "2027-02-01T21:00:00Z" + + +def test_a_notification_queued_before_the_clock_was_set_is_due_once_it_is(): + clock = Clock(now_fn=FakeNow()) + n = Notifications(lambda: clock) + n.notify("Gmail", "New email") + assert n.state()["next_due_at"] is None + clock.set_time(T0, 0) + assert n.state()["next_due_at"] == T0 + + +def test_a_repeating_check_moves_past_a_long_jump_and_says_how_often_it_came_due(): + clock = _frozen_clock() + w = _notifications(clock) + w.schedule("check rugs", at="PT5H", every="PT6H", until="P4D") + clock.set_time("2027-02-02T15:00:00Z", 0) # a day later: due at 20:00, 02:00, 08:00, 14:00 + [item] = w.take({})["items"] + assert (item["at"], item["times"]) == ("2027-02-01T20:00:00Z", 4) + assert w.list()["checks"][0]["next"] == "2027-02-02T20:00:00Z" + + +def test_a_jump_over_a_short_every_is_counted_not_stepped(): + clock = _frozen_clock() + w = _notifications(clock, min_interval="PT0.001S") + w.schedule("tick", every="PT0.001S") + clock.set_time("2027-02-02T15:00:00Z", 0) + [item] = w.take({})["items"] + assert item["times"] == 86_400_000 + assert w.list()["checks"][0]["next"] == "2027-02-02T15:00:00.001000Z" + + +def test_a_repeating_check_ends_at_until(): + clock = _frozen_clock() + w = _notifications(clock) + w.schedule("check", every="P1D", until="P2D") + clock.set_time("2027-02-05T15:00:00Z", 0) + assert w.take({})["items"][0]["times"] == 2 + assert w.list()["checks"] == [] and w.state()["next_due_at"] is None + + +def test_take_until_looks_ahead_without_moving_the_clock(): + w = _notifications() + w.schedule("later", at="PT3H") + assert w.take({"until": "PT1H"})["items"] == [] + assert [_said(i) for i in w.take({"until": "PT3H"})["items"]] == ["later"] + + +def test_a_check_and_a_notification_are_taken_as_one_shape(): + clock = _frozen_clock() + n = _notifications(clock) + n.schedule("See if Dana replied", at="PT2H", every="PT6H") + clock.set_time("2027-02-01T23:10:00Z", 0) + n.notify("Curbfinch alert", "New listing: 2BR on Ivy St", trigger_id="curbfinch-new-listing") + assert n.take({})["items"] == [ + {"id": "check-1", "to": "default", "at": "2027-02-01T17:00:00Z", "times": 2, "source": "scheduled_check", + "source_id": "check-1", "channel": "your scheduled check", + "parts": [{"kind": "text", "text": "See if Dana replied"}]}, + {"id": "notification-2", "to": "default", "at": "2027-02-01T23:10:00Z", "times": 1, "source": "trigger", + "source_id": "curbfinch-new-listing", "channel": "Curbfinch alert", + "parts": [{"kind": "text", "text": "New listing: 2BR on Ivy St"}]}, + ] + + +def test_a_take_retried_with_its_take_id_gets_the_same_batch_back(): + clock = _frozen_clock() + n = Notifications(lambda: clock) + n.notify("Messages", "Jamie: did the rug thing get sorted?") + first = n.take({"take_id": "t-1"}) + assert [_said(i) for i in first["items"]] == ["Jamie: did the rug thing get sorted?"] + assert n.take({"take_id": "t-1"}) == first # the response was lost; the retry still gets the item + assert n.take({"take_id": "t-2"})["items"] == [] # a new take hands nothing out twice + with pytest.raises(NotificationError, match="take_id"): + n.take({"take_id": ""}) + + +def test_notifications_queue_at_the_virtual_time_and_are_taken_once(): + clock = _frozen_clock() + w = Notifications(lambda: clock) # notifications need no switch + w.notify("Messages", "Jamie: did the rug thing get sorted?", trigger_id="t1") + pending = w.state()["pending_notifications"] + assert [(n["channel"], n["at"], n["trigger_id"]) for n in pending] == [("Messages", T0, "t1")] + [item] = w.take({})["items"] + assert (item["source"], _said(item)) == ("trigger", "Jamie: did the rug thing get sorted?") + assert w.take({})["items"] == [] and w.state()["pending_notifications"] == [] + + +def test_due_is_judged_on_the_time_not_its_text(): + clock = _frozen_clock() + w = _notifications(clock) + w.notify("Messages", "on the second") # 15:00:00Z sorts after 15:00:00.500000Z as text + w.schedule("half a second on", at="PT0.5S") + items = w.take({"until": "PT0.75S"})["items"] + assert [_said(i) for i in items] == ["on the second", "half a second on"] + + +def test_turning_checks_off_cancels_the_ones_still_to_come(): + clock = _frozen_clock() + w = _notifications(clock) + w.schedule("sweep", every="P1D") + w.configure({"roles": []}) + clock.set_time("2027-02-05T15:00:00Z", 0) + assert w.state()["next_due_at"] is None and w.take({})["items"] == [] + assert [c["status"] for c in w.state()["checks"]] == ["cancelled"] + + +def test_history_is_bounded_and_delivered_notifications_are_dropped(): + from agent_env.env.gateway import notifications as notifications_mod + w = _notifications() + for n in range(notifications_mod._HISTORY + 10): + w.cancel(w.schedule(f"check {n}", at="PT1H")["check_id"]) + checks = w.state()["checks"] + assert len(checks) == notifications_mod._HISTORY and checks[-1]["note"] == f"check {notifications_mod._HISTORY + 9}" + w.notify("Messages", "hi") + w.take({}) + assert w._queue == [] + + +def test_clear_resets_every_setting_and_forgets_everything(): + w = _notifications(max_active=3, min_interval="PT2H") + w.schedule("x", at="PT1H") + w.notify("Gmail", "hello") + w.clear() + st = w.state() + assert (st["roles"], st["max_active"], st["min_interval"]) == ([], 10, "PT1H") + assert (st["checks"], st["pending_notifications"]) == ([], []) + + +def test_a_config_replaces_every_setting_so_a_field_left_out_goes_back_to_its_default(): + n = _notifications(max_active=3, min_interval="PT2H") + st = n.configure({"max_active": 5}) + assert (st["roles"], st["max_active"], st["min_interval"]) == ([], 5, "PT1H") + assert not n.enabled + + +def test_a_new_config_leaves_existing_checks_as_they_are(): + n = _notifications(max_active=3) + n.schedule("a", at="PT1H") + n.schedule("b", every="PT6H") + n.configure({"roles": ["default"], "max_active": 1, "min_interval": "PT12H"}) + assert [(c["status"], c.get("every")) for c in n.state()["checks"]] == [("active", None), ("active", "PT6H")] + with pytest.raises(NotificationError, match="already have 1"): + n.schedule("c", at="PT2H") + + +@pytest.mark.parametrize("body, error", [ + ({"scheduled_checks": True}, r"not \['scheduled_checks'\]"), + ({"roles": "default"}, "list of role names"), + ({"roles": ["default", " "]}, "roles must name a role"), + ({"roles": ["*"]}, "names each role"), + ({"max_active": 0}, "positive integer"), + ({"min_interval": "1h"}, "min_interval"), +]) +def test_bad_config_is_refused(body, error): + with pytest.raises(NotificationError, match=error): + Notifications(lambda: _frozen_clock()).configure(body) + + +# ---- recipients ---- + +def test_only_the_named_roles_schedule_and_each_lists_and_cancels_only_its_own(): + n = _notifications(roles=["default", "landlord"]) + mine = n.schedule("see if Dana replied", at="PT2H", to="default")["check_id"] + theirs = n.schedule("chase the deposit", at="PT1H", to="landlord")["check_id"] + assert [c["note"] for c in n.list(to="default")["checks"]] == ["see if Dana replied"] + assert [c["note"] for c in n.list(to="landlord")["checks"]] == ["chase the deposit"] + with pytest.raises(NotificationError, match="no active scheduled check"): + n.cancel(theirs, to="default") + n.cancel(mine, to="default") + with pytest.raises(NotificationError, match="not available"): + n.schedule("snoop", at="PT1H", to="neighbour") + + +def test_a_persona_s_check_never_reaches_the_solver(): + clock = _frozen_clock() + n = _notifications(clock, roles=["default", "landlord"]) + n.schedule("chase the deposit", at="PT1H", to="landlord") + n.schedule("see if Dana replied", at="PT2H", to="default") + clock.set_time("2027-02-01T18:00:00Z", 0) + assert [(i["to"], _said(i)) for i in n.take({})["items"]] == [("default", "see if Dana replied")] + assert [(i["to"], _said(i)) for i in n.take({"to": "landlord"})["items"]] == [("landlord", "chase the deposit")] + + +def test_the_active_cap_counts_each_role_s_checks_on_their_own(): + n = _notifications(roles=["default", "landlord"], max_active=1) + n.schedule("a", at="PT1H", to="default") + n.schedule("b", at="PT1H", to="landlord") + with pytest.raises(NotificationError, match="already have 1"): + n.schedule("c", at="PT2H", to="default") + + +def test_a_role_left_out_of_the_config_loses_its_checks_and_the_others_keep_theirs(): + n = _notifications(roles=["default", "landlord"]) + n.schedule("mine", at="PT1H", to="default") + n.schedule("theirs", at="PT1H", to="landlord") + assert n.configure({"roles": ["default"]})["roles"] == ["default"] + assert [(c["to"], c["status"]) for c in n.state()["checks"]] == [("default", "active"), ("landlord", "cancelled")] + + +def test_state_for_one_recipient_shows_only_its_items_and_their_next_due_at(): + clock = _frozen_clock() + n = _notifications(clock, roles=["default", "landlord"]) + n.schedule("chase the deposit", at="PT1H", to="landlord") + n.schedule("see if Dana replied", at="PT3H", to="default") + n.notify("Messages", "Dana: is Tuesday ok?", to="landlord") + solver, landlord, everyone = n.state("default"), n.state("landlord"), n.state() + assert ([c["note"] for c in solver["checks"]], solver["pending_notifications"]) == (["see if Dana replied"], []) + assert solver["next_due_at"] == "2027-02-01T18:00:00Z" + assert landlord["next_due_at"] == T0 and [x["to"] for x in landlord["pending_notifications"]] == ["landlord"] + assert everyone["next_due_at"] == T0 and len(everyone["checks"]) == 2 + + +def test_a_notification_goes_to_the_solver_unless_it_names_a_role(): + n = Notifications(lambda: _frozen_clock()) + n.notify("Gmail", "for the solver") + n.notify("Messages", "for the landlord", to="landlord") + assert [_said(i) for i in n.take({})["items"]] == ["for the solver"] + assert [_said(i) for i in n.take({"to": "landlord"})["items"]] == ["for the landlord"] + + +@pytest.mark.parametrize("call", [lambda n: n.take({"to": ""}), lambda n: n.take({"to": 3}), lambda n: n.state(" ")]) +def test_a_recipient_must_name_a_role(call): + with pytest.raises(NotificationError, match="to must name a role"): + call(Notifications(lambda: _frozen_clock())) + + +# ---- the gateway ---- + +@pytest.fixture +def gateway(tmp_path, monkeypatch) -> Gateway: + monkeypatch.setattr(gw_mod, "GATEWAY_TRAJECTORY_FILE", str(tmp_path / "trajectory.jsonl")) + gw = Gateway(host="127.0.0.1", port=0, server_name="AgentEnvGateway", internal_mcp_servers=[]) + gw._clock = _frozen_clock() + gw._server_tools["mcp-svc"] = [MCPTool(name="svc_thing", description="", inputSchema={"type": "object"})] + gw._tools_discovered = True + yield gw + gw._close_trajectory_file() + + +def _request(gw: Gateway, method: str, path: str, body: dict | None = None, role: str | None = None) -> httpx.Response: + async def _do() -> httpx.Response: + transport = httpx.ASGITransport(app=gw._mcp.streamable_http_app()) + async with httpx.AsyncClient(transport=transport, base_url="http://gateway") as client: + return await client.request(method, path, json=body, + headers={AGENT_ENV_ROLE_HEADER: role} if role else None) + + return asyncio.run(_do()) + + +def _gateway_tool_names(gw: Gateway) -> set[str]: + return {t.name for t in gw._server_tools.get("gateway", [])} + + +def test_the_card_names_the_extension_notifications_and_not_a2a_push_notifications(): + [ext] = [e for e in GATEWAY_EXTENSIONS if e["uri"] == "urn:agentenv:notifications/v1"] + assert "Unrelated to A2A push notifications" in ext["description"] + assert {name: m["endpoint"] for name, m in ext["params"]["methods"].items()} == { + "config": "/notifications/config", "state": "/notifications/state", "take": "/notifications/take", + "clear": "/notifications/clear"} + + +def test_the_tools_appear_only_once_a_task_turns_them_on_and_go_on_clear(gateway): + tools = gateway._mcp._tool_manager._tools + assert not set(CHECK_TOOL_NAMES) & set(tools) + assert _request(gateway, "POST", "/notifications/config", {"max_active": 5}).json()["roles"] == [] + assert not set(CHECK_TOOL_NAMES) & set(tools) # no role named, no tools + r = _request(gateway, "POST", "/notifications/config", {"roles": ["default"], "max_active": 5}) + assert r.status_code == 200 and r.json()["max_active"] == 5 + assert set(CHECK_TOOL_NAMES) <= set(tools) and set(CHECK_TOOL_NAMES) <= _gateway_tool_names(gateway) + assert _request(gateway, "POST", "/notifications/clear", {}).status_code == 200 + assert not set(CHECK_TOOL_NAMES) & set(tools) and not set(CHECK_TOOL_NAMES) & _gateway_tool_names(gateway) + state = _request(gateway, "GET", "/notifications/state").json() + assert (state["roles"], state["max_active"], state["min_interval"]) == ([], 10, "PT1H") + + +def test_the_schedule_tool_states_the_rules_with_the_configured_limits(gateway): + _request(gateway, "POST", "/notifications/config", {"roles": ["default"], "max_active": 3, "min_interval": "PT2H"}) + schedule = gateway._mcp._tool_manager._tools["schedule_check"] + assert schedule.description.endswith("You can have at most 3 checks at once. Times you get back are in UTC.") + described = {name: field["description"] for name, field in schedule.parameters["properties"].items()} + assert described["at"] == ("When it first comes due: a time with seconds and its UTC offset " + "(2027-02-02T18:00:00-08:00), or a duration from now (PT6H).") + assert described["every"] == ("Repeat this often: a duration in weeks, days, hours, minutes or seconds (PT6H, P1D, " + "P1W), at least PT2H. No months or years; P1D is exactly 24 hours, not the same " + "local time each day.") + assert described["until"] == "Only with every: stop after this time, or after this duration from now (not from at)." + + +def test_each_config_rebuilds_the_tools_so_their_descriptions_carry_its_limits(gateway): + def described() -> tuple[str, str, str]: + tool = gateway._mcp._tool_manager._tools["schedule_check"] + [mirrored] = [t for t in gateway._server_tools["gateway"] if t.name == "schedule_check"] + return tool.description, mirrored.description, mirrored.inputSchema["properties"]["every"]["description"] + + _request(gateway, "POST", "/notifications/config", {"roles": ["default"], "max_active": 4}) + assert "at most 4 checks" in described()[0] and "at most 4 checks" in described()[1] + _request(gateway, "POST", "/notifications/config", {"roles": ["default"], "max_active": 7, "min_interval": "PT3H"}) + tool, mirrored, every = described() + assert "at most 7 checks" in tool and "at most 7 checks" in mirrored and "at least PT3H" in every + assert [t.name for t in gateway._server_tools["gateway"]].count("schedule_check") == 1 + + +def test_turning_checks_off_removes_the_tools(gateway): + _request(gateway, "POST", "/notifications/config", {"roles": ["default"]}) + _request(gateway, "POST", "/notifications/config", {"roles": []}) + assert not set(CHECK_TOOL_NAMES) & set(gateway._mcp._tool_manager._tools) + + +def test_config_is_refused_over_a_backing_server_tool_of_the_same_name(gateway): + gateway._tool_server_urls["schedule_check"] = "http://svc:1/mcp" + r = _request(gateway, "POST", "/notifications/config", {"roles": ["default"]}) + assert r.status_code == 409 and "schedule_check" in r.json()["error"] + + +@pytest.mark.parametrize("body", [{"roles": []}, {"max_active": 5}]) +def test_turning_checks_off_is_never_refused_over_a_backing_server_tool_of_the_same_name(gateway, body): + gateway._tool_server_urls["schedule_check"] = "http://svc:1/mcp" + proxied = gateway._mcp._tool_manager._tools["schedule_check"] = object() # the backing server's, as discovery left it + r = _request(gateway, "POST", "/notifications/config", body) + assert r.status_code == 200 and r.json()["roles"] == [] + assert gateway._mcp._tool_manager._tools["schedule_check"] is proxied + + +def test_config_waits_for_tool_discovery_and_sees_what_it_found(gateway, monkeypatch): + gateway._tools_discovered = False + + async def discover(): + gateway._tool_server_urls["schedule_check"] = "http://svc:1/mcp" + gateway._tools_discovered = True + + monkeypatch.setattr(gateway, "_ensure_tools_discovered", discover) + r = _request(gateway, "POST", "/notifications/config", {"roles": ["default"]}) + assert r.status_code == 409 and "schedule_check" in r.json()["error"] + + +def test_config_is_a_503_while_tool_discovery_fails(gateway, monkeypatch): + async def discover(): + raise RuntimeError("backing server down") + + monkeypatch.setattr(gateway, "_ensure_tools_discovered", discover) + r = _request(gateway, "POST", "/notifications/config", {"roles": ["default"]}) + assert r.status_code == 503 and "retry" in r.json()["error"] + assert not set(CHECK_TOOL_NAMES) & set(gateway._mcp._tool_manager._tools) + assert _request(gateway, "POST", "/notifications/config", {"roles": []}).status_code == 200 # off needs no discovery + + +def test_a_bad_config_is_a_400(gateway): + assert _request(gateway, "POST", "/notifications/config", {"max_active": -1}).status_code == 400 + + +def test_the_agent_schedules_through_the_tool_and_the_harness_takes_it(gateway): + _request(gateway, "POST", "/notifications/config", {"roles": ["default"]}) + tool = gateway._mcp._tool_manager._tools["schedule_check"] + made = asyncio.run(tool.fn(note="check rugs", at="PT2H")) + assert made == {"check_id": "check-1", "next": "2027-02-01T17:00:00Z"} + refused = asyncio.run(tool.fn(note="check", every="PT5M")) + assert isinstance(refused, CallToolResult) and refused.isError + state = _request(gateway, "GET", "/notifications/state").json() + assert state["next_due_at"] == "2027-02-01T17:00:00Z" + gateway._clock.set_time("2027-02-01T17:00:00Z", 0) + items = _request(gateway, "POST", "/notifications/take", {}).json()["items"] + assert [_said(i) for i in items] == ["check rugs"] + + +def test_list_is_read_only_so_it_does_not_re_check_state_triggers(gateway): + _request(gateway, "POST", "/notifications/config", {"roles": ["default"]}) + annotations = {t.name: t.annotations for t in gateway._server_tools["gateway"]} + assert annotations["list_scheduled_checks"].readOnlyHint is True + assert annotations["schedule_check"].readOnlyHint is False + + +def test_a_role_the_config_does_not_name_neither_sees_nor_calls_the_tools(gateway): + _request(gateway, "POST", "/notifications/config", {"roles": ["default"]}) + listed = _request(gateway, "POST", "/step", {"action": "list_tools"}, role="landlord").json()["tools"] + assert not {t["name"] for t in listed} & set(CHECK_TOOL_NAMES) + refused = _request(gateway, "POST", "/step", {"action": "call_tool", "tool_name": "schedule_check", + "arguments": {"note": "x", "at": "PT1H"}}, role="landlord") + assert refused.status_code == 403 + token = Caller(role="landlord").bind() + try: + assert not {t.name for t in gateway._mcp._tool_manager.list_tools()} & set(CHECK_TOOL_NAMES) + finally: + Caller.reset(token) + listed = _request(gateway, "POST", "/step", {"action": "list_tools"}, role="default").json()["tools"] + assert set(CHECK_TOOL_NAMES) <= {t["name"] for t in listed} + + +def test_a_check_is_for_the_role_that_scheduled_it_and_only_its_take_hands_it_out(gateway): + _request(gateway, "POST", "/notifications/config", {"roles": ["default", "planner"]}) + made = _request(gateway, "POST", "/step", {"action": "call_tool", "tool_name": "schedule_check", + "arguments": {"note": "re-plan the week", "at": "PT1H"}}, role="planner") + assert made.status_code == 200, made.text + state = _request(gateway, "GET", "/notifications/state?to=planner").json() + assert [(c["to"], c["note"]) for c in state["checks"]] == [("planner", "re-plan the week")] + assert _request(gateway, "GET", "/notifications/state?to=default").json()["checks"] == [] + assert _request(gateway, "GET", "/notifications/state?to=").status_code == 400 + gateway._clock.set_time("2027-02-01T17:00:00Z", 0) + assert _request(gateway, "POST", "/notifications/take", {}).json()["items"] == [] + taken = _request(gateway, "POST", "/notifications/take", {"to": "planner"}).json()["items"] + assert [(i["to"], _said(i)) for i in taken] == [("planner", "re-plan the week")] + + +# ---- notify ---- + +class _FakeGateway: + def __init__(self) -> None: + self.events: list[dict] = [] + self._role_rules_lock = asyncio.Lock() + self._server_tools: dict = {} + self._tool_server_urls: dict = {} + self._clock = _frozen_clock() + self._notifications = Notifications(lambda: self._clock) + self.TOOL_CALL_TIMEOUT_S = 30 + + def _query_changelog_id(self): + return 1 + + async def _log_event(self, event): + self.events.append(dict(event)) + return f"event_{len(self.events)}" + + async def _ensure_tools_discovered(self): + pass + + +def _ok(text: str = "{}") -> CallToolResult: + return CallToolResult(content=[TextContent(type="text", text=text)], isError=False) + + +def _notify_trigger(**action) -> dict: + return {"id": "jamie-texts", "when": {"type": "action", "tool": "messaging_send_message"}, + "actions": [{"type": "notify", "channel": "Messages", "text": "Jamie: ok", **action}]} + + +@pytest.mark.asyncio +async def test_notify_queues_a_templated_notification(): + gw = _FakeGateway() + engine = TriggerEngine(gw) + engine.register({"watch_roles": ["default"], + "triggers": [_notify_trigger(text="${args.to}: got your text about ${args.subject}")]}) + engine.on_tool_call("default", "messaging_send_message", {"to": "Jamie", "subject": "the rug"}, _ok()) + await asyncio.sleep(0.2) + [note] = gw._notifications.state()["pending_notifications"] + assert (note["channel"], note["text"], note["at"]) == ("Messages", "Jamie: got your text about the rug", T0) + ok = [e for e in engine.state()["events"] if e["kind"] == "action_ok"] + assert ok and ok[0]["detail"]["notify"] == "Messages" and "got your text" not in json.dumps(ok[0]) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("text", ["${args.note?}", " ${args.note?} "]) +async def test_notify_whose_text_resolves_to_nothing_queues_no_notification(text): + gw = _FakeGateway() + engine = TriggerEngine(gw) + engine.register({"watch_roles": ["default"], "triggers": [_notify_trigger(text=text)]}) + engine.on_tool_call("default", "messaging_send_message", {"to": "Jamie"}, _ok()) + await asyncio.sleep(0.2) + assert gw._notifications.state()["pending_notifications"] == [] + ok = [e for e in engine.state()["events"] if e["kind"] == "action_ok"] + assert ok and ok[0]["detail"]["skipped"] == "text resolved to nothing" + + +@pytest.mark.parametrize("action, error", [ + ({"channel": ""}, "channel"), + ({"channel": "Your Scheduled Check "}, "the agent's own scheduled checks"), + ({"text": " "}, "text"), + ({"verify": {"tool": "x", "predicate": {"exists": True}}}, "nothing to verify"), + ({"text": "${result.id}", "channel": "Gmail"}, None), +]) +def test_notify_is_validated_at_registration(action, error): + engine = TriggerEngine(_FakeGateway()) + body = {"watch_roles": ["default"], "triggers": [_notify_trigger(**action)]} + if error is None: + engine.register(body) + else: + with pytest.raises(TriggerError, match=error): + engine.register(body) + + +@pytest.mark.asyncio +async def test_notify_to_a_named_role_queues_it_for_that_role(): + gw = _FakeGateway() + engine = TriggerEngine(gw) + engine.register({"watch_roles": ["default"], "triggers": [_notify_trigger(to="landlord")]}) + engine.on_tool_call("default", "messaging_send_message", {"to": "Jamie"}, _ok()) + await asyncio.sleep(0.2) + assert [n["to"] for n in gw._notifications.state()["pending_notifications"]] == ["landlord"] + assert gw._notifications.take({})["items"] == [] + ok = [e for e in engine.state()["events"] if e["kind"] == "action_ok"] + assert ok[0]["detail"]["to"] == "landlord" + + +@pytest.mark.parametrize("action, error", [ + ({"to": ""}, "to must name a role"), + ({"to": ["landlord"]}, "to must name a role"), +]) +def test_notify_s_recipient_is_validated_at_registration(action, error): + engine = TriggerEngine(_FakeGateway()) + with pytest.raises(TriggerError, match=error): + engine.register({"watch_roles": ["default"], "triggers": [_notify_trigger(**action)]}) + + +def test_only_a_notify_action_takes_a_recipient(): + engine = TriggerEngine(_FakeGateway()) + trigger = {"id": "t", "when": {"type": "action", "tool": "x"}, + "actions": [{"type": "tool", "tool": "y", "args": {}, "to": "landlord"}]} + with pytest.raises(TriggerError, match="only valid on notify"): + engine.register({"watch_roles": ["default"], "triggers": [trigger]}) diff --git a/tst/unit/task_step/capture_stubs.py b/tst/unit/task_step/capture_stubs.py index d18aae258..7fb44c6e8 100644 --- a/tst/unit/task_step/capture_stubs.py +++ b/tst/unit/task_step/capture_stubs.py @@ -196,6 +196,7 @@ def __init__(self, name="solver", a2a_url="https://agent", card=None): self.a2a_card = card if card is not None else agent_card() self.sandbox_id = "sb-agent" self.sandbox_type = None + self.role = None def context(*, agent: Optional[_StubAgent] = None, env_id: Optional[str] = None) -> TaskStepContext: diff --git a/tst/unit/task_step/test_advance_to_next_event_hook.py b/tst/unit/task_step/test_advance_to_next_event_hook.py new file mode 100644 index 000000000..ec86d9d86 --- /dev/null +++ b/tst/unit/task_step/test_advance_to_next_event_hook.py @@ -0,0 +1,283 @@ +"""The advance-to-next-event example hook, turn by turn, against an in-process gateway's real clock, triggers and +notifications, with what came due delivered as prompt_agent delivers it.""" + +from __future__ import annotations + +import asyncio +import importlib.util +from pathlib import Path + +import httpx +import pytest +from mcp.types import CallToolResult, TextContent + +import agent_env +from agent_env.env.env import DeployedGatewayEnv +from agent_env.env.gateway import gateway as gw_mod +from agent_env.env.gateway.clock import Clock +from agent_env.env.gateway.constants import GATEWAY_EXTENSIONS, WELL_KNOWN_PATH +from agent_env.env.gateway.gateway import Gateway +from agent_env.task_step.context import TaskStepContext +from agent_env.task_step.task_steps.notification_delivery import delivered_parts, next_message, take_due +from agent_env.task_step.task_steps.post_turn_hooks import PostTurnHook, PostTurnHooks, Turn, _Answer, run_post_turn_hooks + +HOOK = Path(agent_env.__file__).parent / "examples" / "advance-to-next-event" / "artifacts" / "advance-to-next-event" / "hook.py" +GATEWAY = "http://gateway.test" +MONDAY = "2026-03-02T08:00:00Z" + + +@pytest.fixture +def hook(monkeypatch): + spec = importlib.util.spec_from_file_location("advance_to_next_event_hook", HOOK) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + monkeypatch.setattr(module, "POLL_SECONDS", 0.01) + return module + + +@pytest.fixture +def gateway(tmp_path, monkeypatch): + """A gateway on a frozen clock armed Monday 08:00 whose world writes record the virtual time they ran at.""" + monkeypatch.setattr(gw_mod, "GATEWAY_TRAJECTORY_FILE", str(tmp_path / "trajectory.jsonl")) + gw = Gateway(host="127.0.0.1", port=0, server_name="AgentEnvGateway", internal_mcp_servers=[]) + gw._clock = Clock() + gw._clock.set_time(MONDAY, rate=0) + gw._tool_server_urls = {"items_add_item": "http://items/mcp"} + gw.calls = [] + + async def call_child_tool(server_url, tool_name, arguments, *, caller=None): + gw.calls.append((arguments["item"], gw._clock.read()["virtual_time"])) + return CallToolResult(content=[TextContent(type="text", text="ok")], isError=False) + + gw._call_child_tool = call_child_tool + gw._notifications.configure({"roles": ["default"]}) + real_client = httpx.AsyncClient + transport = httpx.ASGITransport(app=gw._mcp.streamable_http_app()) + monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **kw: real_client(*a, **{**kw, "transport": transport})) + yield gw + gw._close_trajectory_file() + + +def _context() -> TaskStepContext: + card = {"capabilities": {"extensions": list(GATEWAY_EXTENSIONS)}} + env = DeployedGatewayEnv(env_id="items", env_version=1, gateway_url=GATEWAY, mcp_url="", sandbox_id="sb", + environment_card_url=f"{GATEWAY}{WELL_KNOWN_PATH}", environment_card=card, + metadata={"deploy_step_id": "deploy_items"}) + return TaskStepContext(deployed_envs=[env]) + + +async def _turns(hook, context, params=None, on_turn=None, triggers=None) -> list: + """What prompt_agent sends the agent after each turn until the conversation ends: each message's part texts (what + came due for the solver's role first, then the hook's or a trigger's reply), then the end. ``on_turn(n)`` plays + the agent's turn n first.""" + answers = list(triggers or []) + + async def user_triggers(): + return answers.pop(0) if answers else None + + async def ask_user(): + raise AssertionError("the hook never asks the user side") + + hooks = PostTurnHooks() + hooks.hooks.append(PostTurnHook("advance-to-next-event", None, dict(params or {}), hook.post_turn)) + sent = [] + while not sent or not isinstance(sent[-1], dict): + assert len(sent) < 12, sent + number = len(sent) + 1 + if on_turn: + on_turn(number) + answer = await run_post_turn_hooks(hooks, context=context, step_id="week", number=number, last=False, + agent_reply="ok", agent_parts=[{"kind": "text", "text": "ok"}], + ask_user=ask_user, user_triggers=user_triggers) + if "end" in answer: + sent.append(answer) + continue + due = await take_due(context.deployed_envs, "default") + message = next_message(delivered_parts(due), answer["message"]) + sent.append([p["text"] for p in message] if message else {"end": "nothing_to_send"}) + return sent + + +def _turn(hook, context, number=1) -> Turn: + async def never(): + raise AssertionError("not asked") + + async def no_trigger(): + return None + + return Turn(context=context, step_id="week", hook=PostTurnHook("advance-to-next-event", None, {}, hook.post_turn), + answer=_Answer(never, no_trigger), number=number, last=False, agent_reply="ok", + agent_parts=[{"kind": "text", "text": "ok"}]) + + +def _register(gw: Gateway, *triggers: dict) -> None: + gw._trigger_engine.register({"watch_roles": ["default"], "triggers": list(triggers)}) + + +@pytest.mark.asyncio +async def test_the_hook_moves_the_clock_to_what_is_due_and_leaves_it_for_prompt_agent_to_take(hook, gateway): + gateway._notifications.schedule("check for new rugs", at="2026-03-03T18:00:00Z") + + assert await hook.post_turn(_turn(hook, _context()), {}) == {"message": []} + + assert gateway._clock.read()["virtual_time"] == "2026-03-03T18:00:00Z" + assert gateway._notifications.state("default")["next_due_at"] == "2026-03-03T18:00:00Z" + + +@pytest.mark.asyncio +async def test_a_world_event_with_no_channel_happens_silently_and_the_agent_s_own_check_wakes_it(hook, gateway): + _register(gateway, {"id": "w-1", "when": {"type": "time", "at": "2026-03-03T09:00:00Z"}, + "actions": [{"type": "tool", "tool": "items_add_item", "args": {"item": "rug listed"}}]}) + + def agent(n): + if n == 1: + gateway._notifications.schedule("check for new rugs", at="2026-03-03T18:00:00Z") + + sent = await _turns(hook, _context(), on_turn=agent) + + assert sent == [["[Tue Mar 3, 6:00 PM UTC · your scheduled check] check for new rugs"], + {"end": "nothing_can_wake_the_agent"}] + assert gateway.calls == [("rug listed", "2026-03-03T09:00:00Z")] + + +@pytest.mark.asyncio +async def test_a_notify_action_wakes_the_agent_when_it_fires_with_its_channel(hook, gateway): + _register(gateway, {"id": "w-2", "when": {"type": "time", "at": "2026-03-03T09:00:00Z"}, + "actions": [{"type": "notify", "channel": "Curbfinch alert", + "text": 'New for "rug 8x10": 1970s wool rug · $360'}]}) + sent = await _turns(hook, _context()) + assert sent == [['[Tue Mar 3, 9:00 AM UTC · Curbfinch alert] New for "rug 8x10": 1970s wool rug · $360'], + {"end": "nothing_can_wake_the_agent"}] + + +@pytest.mark.asyncio +async def test_only_what_is_due_for_the_agent_s_role_wakes_it(hook, gateway): + _register(gateway, {"id": "landlord", "when": {"type": "time", "at": "2026-03-03T09:00:00Z"}, + "actions": [{"type": "notify", "channel": "Gmail", "to": "landlord", "text": "rent is due"}]}) + gateway._notifications.schedule("check for new rugs", at="2026-03-04T09:00:00Z") + + sent = await _turns(hook, _context()) + + assert sent == [["[Wed Mar 4, 9:00 AM UTC · your scheduled check] check for new rugs"], + {"end": "nothing_can_wake_the_agent"}] + assert [n["to"] for n in gateway._notifications.state()["pending_notifications"]] == ["landlord"] + + +@pytest.mark.asyncio +async def test_the_person_s_messages_arrive_at_their_times_once_each(hook, gateway): + params = {"person_messages": [{"at": "2026-03-04T16:30:00Z", "text": "got Leo, we're home"}, + {"at": "2026-03-03T15:00:00Z", "text": "I'm up"}]} + sent = await _turns(hook, _context(), params=params) + assert sent == [["[Tue Mar 3, 3:00 PM UTC] I'm up"], + ["[Wed Mar 4, 4:30 PM UTC] got Leo, we're home"], + {"end": "nothing_can_wake_the_agent"}] + + +@pytest.mark.asyncio +async def test_another_conversation_delivering_a_message_at_the_same_time_does_not_skip_this_one(hook, gateway): + context = _context() + context.metadata["post_turn_hooks"] = {"other": [{"hook": "advance-to-next-event", "turn": 1, + "person_at": ["2026-03-03T15:00:00Z"]}]} + params = {"person_messages": [{"at": "2026-03-03T15:00:00Z", "text": "I'm up"}]} + sent = await _turns(hook, context, params=params) + assert sent == [["[Tue Mar 3, 3:00 PM UTC] I'm up"], {"end": "nothing_can_wake_the_agent"}] + + +@pytest.mark.asyncio +async def test_what_comes_due_together_goes_in_one_message_oldest_first(hook, gateway): + _register(gateway, {"id": "w-3", "when": {"type": "time", "at": "2026-03-03T09:00:00Z"}, + "actions": [{"type": "notify", "channel": "Gmail", "text": "School: early dismissal"}]}) + + def agent(n): + if n == 1: + gateway._notifications.schedule("morning sweep", at="2026-03-03T09:00:00Z") + + [first, end] = await _turns(hook, _context(), on_turn=agent, + params={"person_messages": [{"at": "2026-03-03T09:00:00Z", "text": "pick up Leo?"}]}) + assert first == ["[Tue Mar 3, 9:00 AM UTC · your scheduled check] morning sweep", + "[Tue Mar 3, 9:00 AM UTC · Gmail] School: early dismissal", + "[Tue Mar 3, 9:00 AM UTC] pick up Leo?"] + + +@pytest.mark.asyncio +async def test_it_ends_at_end_time_without_waking_for_what_lies_beyond(hook, gateway): + gateway._notifications.schedule("next week", at="2026-03-10T09:00:00Z") + assert await _turns(hook, _context(), params={"end_time": "P5D"}) == [{"end": "end_time"}] + + +@pytest.mark.asyncio +async def test_a_state_check_the_agents_turn_set_off_finishes_before_the_clock_jumps(hook, gateway, monkeypatch): + async def slow_check(check, ctx=None, role=None): + await asyncio.sleep(0.3) + return True + + monkeypatch.setattr(gateway._trigger_engine, "_run_check", slow_check) + _register(gateway, {"id": "seen", "when": {"type": "state", "check": {"tool": "items_list", "predicate": {"exists": True}}}, + "actions": [{"type": "tool", "tool": "items_add_item", "args": {"item": "seen"}}]}, + {"id": "tue", "when": {"type": "time", "at": "2026-03-03T09:00:00Z"}, + "actions": [{"type": "tool", "tool": "items_add_item", "args": {"item": "tue"}}]}) + gateway._trigger_engine.on_tool_call("default", "send_email", {}, CallToolResult(content=[], isError=False)) + await _turns(hook, _context()) + assert gateway.calls[0] == ("seen", MONDAY) + + +@pytest.mark.asyncio +async def test_a_firing_agent_trigger_answers_before_any_jump(hook, gateway): + gateway._notifications.schedule("later", at="2026-03-03T09:00:00Z") + pushback = {"message": [{"kind": "text", "text": "the person pushes back"}]} + sent = await _turns(hook, _context(), triggers=[pushback]) + assert sent[0] == ["the person pushes back"] + assert sent[1] == ["[Tue Mar 3, 9:00 AM UTC · your scheduled check] later"] + + +@pytest.mark.asyncio +async def test_a_repeating_check_wakes_once_per_time_and_the_record_names_why(hook, gateway): + gateway._notifications.schedule("sweep", every="P1D", until="P2D") + context = _context() + sent = await _turns(hook, context) + assert sent == [["[Tue Mar 3, 8:00 AM UTC · your scheduled check] sweep"], + ["[Wed Mar 4, 8:00 AM UTC · your scheduled check] sweep"], + {"end": "nothing_can_wake_the_agent"}] + records = context.metadata["post_turn_hooks"]["week"] + assert [r.get("for") for r in records[:2]] == [["scheduled_check"], ["scheduled_check"]] + + +@pytest.mark.asyncio +async def test_what_the_last_allowed_jump_makes_due_still_wakes_the_agent(hook, gateway): + gateway._notifications.schedule("check for new rugs", at="2026-03-03T18:00:00Z") + sent = await _turns(hook, _context(), params={"max_jumps_per_turn": 1}) + assert sent[0] == ["[Tue Mar 3, 6:00 PM UTC · your scheduled check] check for new rugs"] + + +@pytest.mark.asyncio +async def test_triggers_still_firing_at_the_settle_timeout_end_the_run_saying_so(hook, gateway, monkeypatch): + _register(gateway, {"id": "w-1", "when": {"type": "time", "at": "2026-03-03T09:00:00Z"}, + "actions": [{"type": "notify", "channel": "Gmail", "text": "late"}]}) + monkeypatch.setattr(hook, "_busy", lambda row, now: True) + context = _context() + sent = await _turns(hook, context, params={"settle_timeout_seconds": 0.05}) + assert sent == [{"end": "triggers_still_firing"}] + assert context.metadata["post_turn_hooks"]["week"][-1]["envs"] == ["items"] + + +class _UnarmedEnv: + env_id = "unarmed" + + def supports(self, extension, method): + return True + + async def invoke(self, extension, method, args=None): + return {"armed": False} + + +@pytest.mark.asyncio +async def test_an_env_whose_clock_was_never_armed_is_left_out(hook, gateway): + armed = _turn(hook, _context()).envs[0] + assert await hook._armed([armed, _UnarmedEnv()]) == [armed] + gateway._clock = Clock() + assert await _turns(hook, _context()) == [{"end": "no_armed_env_with_clock_and_notifications"}] + + +def test_unknown_params_are_refused(hook): + with pytest.raises(ValueError, match="takes"): + hook._params({"nudge": "x"}) diff --git a/tst/unit/task_step/test_configure_scheduled_checks.py b/tst/unit/task_step/test_configure_scheduled_checks.py new file mode 100644 index 000000000..8702651e2 --- /dev/null +++ b/tst/unit/task_step/test_configure_scheduled_checks.py @@ -0,0 +1,90 @@ +"""ConfigureScheduledChecksTaskStep posts the env's notifications/v1 config as the stored card declares it and records +it, and refuses before any request on a gateway without notifications/v1. Only the gateway HTTP boundary is mocked.""" + +from __future__ import annotations + +import json + +import httpx +import pytest + +from agent_env.env.env import DeployedEnv, DeployedGatewayEnv +from agent_env.env.gateway.constants import EXT_NOTIFICATIONS_URI, GATEWAY_EXTENSIONS, WELL_KNOWN_PATH +from agent_env.task_step.context import TaskStepContext +from agent_env.task_step.registry import get_task_step_registry +from agent_env.task_step.task_steps.configure_scheduled_checks import ConfigureScheduledChecksTaskStep + +_GATEWAY = "http://gw:18765" + + +@pytest.mark.asyncio +async def test_posts_the_config_and_records_what_the_gateway_applied(monkeypatch): + applied = {"roles": ["default", "planner"], "max_active": 5, "min_interval": "PT1H"} + sent = _mock_gateway(monkeypatch, {**applied, "next_due_at": None, "checks": []}) + step = _step(roles=["default", "planner"], max_active=5) + out = await step.execute(TaskStepContext(deployed_envs=[_record()], metadata={})) + [request] = sent + assert (request.method, str(request.url)) == ("POST", f"{_GATEWAY}/notifications/config") + assert json.loads(request.content) == applied + [entry] = out.metadata["scheduled_check_configurations"] + assert entry == {"step_id": "checks-1", "env_id": "env-x", **applied, "applied": applied} + + +@pytest.mark.asyncio +async def test_a_gateway_without_notifications_refuses_before_any_request(monkeypatch): + sent = _mock_gateway(monkeypatch, {}) + older = [e for e in GATEWAY_EXTENSIONS if e["uri"] != EXT_NOTIFICATIONS_URI] + with pytest.raises(RuntimeError, match="without urn:agentenv:notifications/v1"): + await _step().execute(TaskStepContext(deployed_envs=[_record(older)], metadata={})) + assert sent == [] + + +@pytest.mark.asyncio +async def test_a_refused_config_raises_with_the_gateway_s_reason(monkeypatch): + _mock_gateway(monkeypatch, {"ok": False, "error": "['schedule_check'] already registered by a backing server"}, + status=409) + with pytest.raises(RuntimeError, match="HTTP 409.*schedule_check"): + await _step().execute(TaskStepContext(deployed_envs=[_record()], metadata={})) + + +def test_round_trips_and_is_registered(): + step = _step(max_active=3, min_interval="PT2H") + again = ConfigureScheduledChecksTaskStep.from_dict(step.to_dict()) + assert (again.max_active, again.min_interval, again.roles) == (3, "PT2H", ["default"]) + assert get_task_step_registry()["configure_scheduled_checks"] is ConfigureScheduledChecksTaskStep + + +@pytest.mark.parametrize("kwargs", [{"max_active": 0}, {"roles": []}, {"roles": None}, {"roles": "default"}, + {"roles": ["default", ""]}]) +def test_bad_settings_are_refused_when_the_step_is_built(kwargs): + with pytest.raises(ValueError): + _step(**kwargs) + + +def test_a_step_must_name_the_roles_that_get_the_tools(): + stored = _step().to_dict() + del stored["roles"] + with pytest.raises(ValueError, match="roles must name"): + ConfigureScheduledChecksTaskStep.from_dict(stored) + + +def _step(**kwargs) -> ConfigureScheduledChecksTaskStep: + return ConfigureScheduledChecksTaskStep(id="checks-1", version=None, env_id="env-x", + **{"roles": ["default"], **kwargs}) + + +def _record(extensions: list = GATEWAY_EXTENSIONS) -> DeployedEnv: + return DeployedGatewayEnv(env_id="env-x", env_version=1, gateway_url=_GATEWAY, mcp_url="", db_web_url=None, + sandbox_id="sb", environment_card_url=f"{_GATEWAY}{WELL_KNOWN_PATH}", + environment_card={"name": "gw", "capabilities": {"extensions": extensions}}) + + +def _mock_gateway(monkeypatch, body: dict, status: int = 200) -> list[httpx.Request]: + sent, real = [], httpx.AsyncClient + + def handle(request: httpx.Request) -> httpx.Response: + sent.append(request) + return httpx.Response(status, json=body) + + monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **k: real(transport=httpx.MockTransport(handle))) + return sent diff --git a/tst/unit/task_step/test_prompt_agent_notifications.py b/tst/unit/task_step/test_prompt_agent_notifications.py new file mode 100644 index 000000000..b3aa579e1 --- /dev/null +++ b/tst/unit/task_step/test_prompt_agent_notifications.py @@ -0,0 +1,290 @@ +"""prompt_agent delivers what came due on its envs' notifications/v1: after each turn that the conversation goes on +from, at the start of the agent's next message, ahead of the user side's reply. The env answers take from a real +gateway queue (``Notifications``) on a frozen clock; the agent, user side and env are scripted over HTTP.""" + +from __future__ import annotations + +import json + +import httpx +import pytest + +from agent_env.a2a_agent import conversation_store +from agent_env.env.gateway.clock import Clock +from agent_env.env.gateway.constants import EXT_NOTIFICATIONS_URI +from agent_env.env.gateway.notifications import Notifications +from agent_env.task_step.task_steps import notification_delivery +from agent_env.task_step.task_steps.notification_delivery import delivered_line, next_message +from agent_env.task_step.task_steps.prompt_agent import PromptAgentTaskStep +from tst.unit.task_step.test_prompt_agent_post_turn_hooks import EnvPeers, _env, _hook +from tst.unit.task_step.test_prompt_agent_user_side import AGENT, ENV, USER, _USER_SIM_CARD, _context, sim, text + +pytestmark = pytest.mark.usefixtures("local_stores") + +MONDAY = "2027-02-01T15:00:00Z" # 7:00 AM in Los Angeles +ALDER = "See if Dana replied about the 2BR on Alder St" +IVY = "New listing: 2BR on Ivy St, $3,050/mo" + + +class GatewayQueue(EnvPeers): + """``EnvPeers`` whose env answers notifications/v1 take from a real ``Notifications`` on a frozen clock.""" + + def __init__(self, monkeypatch, replies, *, fail_take: bool = False, **kw): + super().__init__(monkeypatch, replies, **kw) + self.gw_clock = Clock(now_fn=lambda: 1000.0) + self.gw_clock.set_time(MONDAY, 0) + self.queue = Notifications(lambda: self.gw_clock) + self.queue.configure({"roles": ["default", "planner"]}) + self.takes: list[dict] = [] + self.fail_take = fail_take + + def _answer(self, request: httpx.Request) -> httpx.Response: + origin = f"{request.url.scheme}://{request.url.host}" + if origin == ENV and request.url.path == "/notifications/take": + body = json.loads(request.content) + assert body.pop("take_id") # fresh per take, reused only by its own retries + self.takes.append(body) + if self.fail_take: + return httpx.Response(503, json={"ok": False, "error": "gateway restarting"}) + return httpx.Response(200, json=self.queue.take(body)) + return super()._answer(request) + + def at(self, when: str) -> None: + self.gw_clock.set_time(when, 0) + + +def _ctx(*, role: str | None = None, user_sim: bool = True): + ctx = _context(user_card=_USER_SIM_CARD if user_sim else None) + ctx.deployed_agents[0].role = role + ctx.deployed_envs.append(_env(EXT_NOTIFICATIONS_URI)) + return ctx + + +def _step(turns: int = 4, **kw) -> PromptAgentTaskStep: + return PromptAgentTaskStep(id="talk", version=None, prompt="find me a 2BR", agent_name="solver", + poll_interval_seconds=0, max_conversation_turns=turns, **kw) + + +def _to_agent(ctx) -> list[list[dict]]: + """Each message the agent was sent, in order.""" + doc = conversation_store.get_conversation(ctx.metadata["a2a_conversations"]["talk"]) + return [m["parts"] for m in doc["messages"] if m["role"] == "user"] + + +def _alder_and_ivy_due(peers: GatewayQueue) -> None: + """A check the agent scheduled for 3:00 PM and a Curbfinch alert at 3:10 PM, Los Angeles time.""" + peers.queue.schedule(ALDER, at="2027-02-01T23:00:00Z") + peers.at("2027-02-01T23:10:00Z") + peers.queue.notify("Curbfinch alert", IVY, trigger_id="ivy-listed") + + +# ---- with the user sim ---------------------------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_what_came_due_opens_the_next_message_ahead_of_the_user_sims_reply(monkeypatch): + peers = GatewayQueue(monkeypatch, {AGENT: [text("two listings so far"), text("Fremont too, noted")], + USER: [sim("Actually, can we look at places in Fremont too?"), + sim("great", done=True)]}) + _alder_and_ivy_due(peers) + ctx = _ctx() + + await _step().execute(ctx) + + assert _to_agent(ctx)[1] == [ + {"kind": "text", "text": f"[Mon Feb 1, 11:00 PM UTC · your scheduled check] {ALDER}", + "metadata": {"agentenv": {"id": "check-1", "to": "default", "at": "2027-02-01T23:00:00Z", "times": 1, + "source": "scheduled_check", "source_id": "check-1", + "channel": "your scheduled check", "env_id": "env"}}}, + {"kind": "text", "text": f"[Mon Feb 1, 11:10 PM UTC · Curbfinch alert] {IVY}", + "metadata": {"agentenv": {"id": "notification-2", "to": "default", "at": "2027-02-01T23:10:00Z", "times": 1, + "source": "trigger", "source_id": "ivy-listed", "channel": "Curbfinch alert", + "env_id": "env"}}}, + {"kind": "text", "text": "Actually, can we look at places in Fremont too?"}, + ] + assert peers.takes == [{"to": "default"}] # the user sim's done ended the run: its turn took nothing + + +@pytest.mark.asyncio +async def test_nothing_due_leaves_every_message_as_it_was(monkeypatch): + peers = GatewayQueue(monkeypatch, {AGENT: [text("draft one"), text("draft two")], + USER: [sim("make it shorter"), sim("perfect", done=True)]}) + ctx = _ctx() + + await _step().execute(ctx) + + assert _to_agent(ctx) == [text("find me a 2BR"), text("make it shorter")] + assert peers.takes == [{"to": "default"}] + + +@pytest.mark.asyncio +async def test_an_answer_that_ends_the_conversation_takes_nothing_and_leaves_it_queued(monkeypatch): + peers = GatewayQueue(monkeypatch, {AGENT: [text("done looking")], USER: [sim("thanks", done=True)]}) + _alder_and_ivy_due(peers) + ctx = _ctx() + + await _step().execute(ctx) + + assert peers.takes == [] and _to_agent(ctx) == [text("find me a 2BR")] + assert peers.queue.state("default")["next_due_at"] == "2027-02-01T23:00:00Z" + assert len(peers.queue.state("default")["pending_notifications"]) == 1 + + +@pytest.mark.asyncio +async def test_the_last_turn_takes_nothing(monkeypatch): + peers = GatewayQueue(monkeypatch, {AGENT: [text("one")]}) + _alder_and_ivy_due(peers) + + await _step(turns=1).execute(_ctx()) + + assert peers.takes == [] + + +@pytest.mark.asyncio +async def test_labels_are_in_utc_and_say_so(monkeypatch): + peers = GatewayQueue(monkeypatch, {AGENT: [text("one"), text("two")], USER: [sim("ok"), sim("bye", done=True)]}) + _alder_and_ivy_due(peers) + ctx = _ctx() + + await _step().execute(ctx) + + assert [p["text"] for p in _to_agent(ctx)[1]] == [f"[Mon Feb 1, 11:00 PM UTC · your scheduled check] {ALDER}", + f"[Mon Feb 1, 11:10 PM UTC · Curbfinch alert] {IVY}", "ok"] + + +@pytest.mark.asyncio +async def test_only_what_is_due_for_the_agents_own_role_reaches_it(monkeypatch): + peers = GatewayQueue(monkeypatch, {AGENT: [text("one"), text("two")], USER: [sim("ok"), sim("bye", done=True)]}) + peers.queue.notify("Messages", "for the solver role") + peers.queue.notify("Calendar", "for the planner", to="planner") + ctx = _ctx(role="planner") + + await _step().execute(ctx) + + assert peers.takes == [{"to": "planner"}] + assert [p["text"] for p in _to_agent(ctx)[1]] == ["[Mon Feb 1, 3:00 PM UTC · Calendar] for the planner", "ok"] + assert [n["text"] for n in peers.queue.state("default")["pending_notifications"]] == ["for the solver role"] + + +@pytest.mark.asyncio +async def test_a_take_that_keeps_failing_fails_the_step_rather_than_drop_what_is_due(monkeypatch): + monkeypatch.setattr(notification_delivery, "_TAKE_ATTEMPTS", 1) + GatewayQueue(monkeypatch, {AGENT: [text("one")], USER: [sim("ok")]}, fail_take=True) + + with pytest.raises(RuntimeError, match="could not take what came due for role 'default' from env 'env'"): + await _step().execute(_ctx()) + + +# ---- with a post-turn hook ------------------------------------------------------------------------------------------ + +SAYS_NOTHING = """ + async def post_turn(turn, params): + return None if turn.last else {"message": []} +""" +SAYS_SOMETHING = """ + async def post_turn(turn, params): + return None if turn.last else {"message": "[Tue] the person writes"} +""" + + +@pytest.mark.asyncio +async def test_a_hook_that_says_nothing_sends_only_what_came_due_and_the_run_ends_when_nothing_has(monkeypatch): + peers = GatewayQueue(monkeypatch, {AGENT: [text("watching"), text("on it")]}) + _alder_and_ivy_due(peers) + ctx = _ctx(user_sim=False) + + await _step(**_hook_spec(_hook("quiet", SAYS_NOTHING))).execute(ctx) + + assert [[p["text"] for p in parts] for parts in _to_agent(ctx)] == [ + ["find me a 2BR"], + [f"[Mon Feb 1, 11:00 PM UTC · your scheduled check] {ALDER}", f"[Mon Feb 1, 11:10 PM UTC · Curbfinch alert] {IVY}"], + ] + assert peers.takes == [{"to": "default"}, {"to": "default"}] # the second found nothing, so the run ended + doc = conversation_store.get_conversation(ctx.metadata["a2a_conversations"]["talk"]) + assert doc["status"] == "closed" + + +@pytest.mark.asyncio +async def test_what_came_due_goes_ahead_of_a_hooks_message(monkeypatch): + peers = GatewayQueue(monkeypatch, {AGENT: [text("one"), text("two")]}) + peers.queue.notify("Messages", "Jamie: did the rug thing get sorted?") + ctx = _ctx(user_sim=False) + await _step(turns=2, **_hook_spec(_hook("says", SAYS_SOMETHING))).execute(ctx) + + assert [p["text"] for p in _to_agent(ctx)[1]] == [ + "[Mon Feb 1, 3:00 PM UTC · Messages] Jamie: did the rug thing get sorted?", "[Tue] the person writes"] + + +def _hook_spec(hook_id: str) -> dict: + return {"post_turn_hooks": [{"file_artifact_id": hook_id}]} + + +# ---- the step field and the message builder ------------------------------------------------------------------------- + +def test_time_zone_is_not_a_step_field(): + with pytest.raises(TypeError): + _step(time_zone="America/Los_Angeles") + + +def _item(source: str, channel: str, text: str, at: str | None = MONDAY, times: int = 1) -> dict: + return {"id": "x-1", "to": "default", "at": at, "times": times, "source": source, "source_id": "x", + "channel": channel, "parts": [{"kind": "text", "text": text}]} + + +def test_every_item_is_labelled_with_its_channel_and_one_that_came_due_more_than_once_says_so(): + check = _item("scheduled_check", "your scheduled check", "check rugs", times=4) + assert delivered_line(check) == ( + "[Mon Feb 1, 3:00 PM UTC · your scheduled check] check rugs (it came due 4 times while you were away)") + queued_unclocked = _item("trigger", "Gmail", "hi", at=None) + assert delivered_line(queued_unclocked) == "[now · Gmail] hi" + unknown_source = _item("calendar", "Calendar", "Standup in 10 minutes") + assert delivered_line(unknown_source) == "[Mon Feb 1, 3:00 PM UTC · Calendar] Standup in 10 minutes" + + +def test_a_delivered_part_carries_the_item_without_its_parts_and_a_part_that_is_not_text_follows_as_it_is(): + item = {**_item("trigger", "Gmail", "New email"), "env_id": "env"} + item["parts"].append({"kind": "data", "data": {"thread": "t-9"}}) + meta = {"agentenv": {k: v for k, v in item.items() if k != "parts"}} + assert notification_delivery.delivered_parts([item]) == [ + {"kind": "text", "text": "[Mon Feb 1, 3:00 PM UTC · Gmail] New email", "metadata": meta}, + {"kind": "data", "data": {"thread": "t-9"}, "metadata": meta}, + ] + + +def test_a_blank_reply_adds_nothing_to_delivered_lines_and_without_them_is_sent_as_it_was(): + delivered = [{"kind": "text", "text": "[now · Gmail] hi"}] + assert next_message(delivered, [{"kind": "text", "text": " "}]) == delivered + assert next_message([], [{"kind": "text", "text": " "}]) == [{"kind": "text", "text": " "}] + assert next_message(delivered, text("and me")) == [*delivered, *text("and me")] + + +class _FlakyEnv: + """An env whose first take is applied but its response lost, as a dropped connection would.""" + + env_id = "flaky" + + def __init__(self, notifications): + self.notifications = notifications + self.bodies = [] + + def supports(self, extension, method): + return extension == EXT_NOTIFICATIONS_URI + + async def invoke(self, extension, method, body): + self.bodies.append(dict(body)) + answer = self.notifications.take(body) + if len(self.bodies) == 1: + raise RuntimeError("connection reset") + return answer + + +@pytest.mark.asyncio +async def test_a_take_retried_after_a_lost_response_still_delivers_the_item(monkeypatch): + monkeypatch.setattr(notification_delivery, "_TAKE_RETRY_BASE_DELAY_S", 0) + clock = Clock() + clock.set_time("2027-02-01T15:00:00Z", rate=0) + n = Notifications(lambda: clock) + n.notify("Messages", "Jamie: did the rug thing get sorted?") + env = _FlakyEnv(n) + items = await notification_delivery.take_due([env], "default") + assert [i["parts"][0]["text"] for i in items] == ["Jamie: did the rug thing get sorted?"] + assert env.bodies[0]["take_id"] == env.bodies[1]["take_id"] diff --git a/tst/unit/task_step/test_prompt_agent_post_turn_hooks.py b/tst/unit/task_step/test_prompt_agent_post_turn_hooks.py index af66395a4..f54d8a200 100644 --- a/tst/unit/task_step/test_prompt_agent_post_turn_hooks.py +++ b/tst/unit/task_step/test_prompt_agent_post_turn_hooks.py @@ -39,7 +39,8 @@ class EnvPeers(Peers): - """``Peers`` plus the env's clock and /step, so hooks and the env clock stamp have a gateway to read.""" + """``Peers`` plus the env's clock, /step and notifications take (nothing due), so hooks, the env clock stamp and + delivery have a gateway to read.""" def __init__(self, monkeypatch, replies, *, clock: dict | None = None, engine: TriggerEngine | None = None, **kw): super().__init__(monkeypatch, replies, **kw) @@ -55,6 +56,10 @@ def _answer(self, request: httpx.Request) -> httpx.Response: if origin == ENV and request.url.path == "/clock/state": self.raw.append((request.method, str(request.url), None, request.extensions["timeout"]["read"])) return httpx.Response(200, json=self.clock) + if origin == ENV and request.url.path == "/notifications/take": + body = json.loads(request.content) + self.raw.append((request.method, str(request.url), body, request.extensions["timeout"]["read"])) + return httpx.Response(200, json={"to": body.get("to", "default"), "items": [], "until": None}) if origin == ENV and request.url.path == "/step": self.steps.append((request.headers.get("AgentEnv-Role"), json.loads(request.content))) return httpx.Response(200, json={"content": [{"type": "text", "text": "ok"}], "changelog_id": 7}) @@ -264,7 +269,7 @@ async def post_turn(turn, params): ("'wake'", "returned 'wake'"), ("{'message': 'x', 'mood': 'calm'}", "returned {'message': 'x', 'mood': 'calm'}"), ("{'end': ''}", "the reason is a string"), - ("{'message': []}", "a message is text or a non-empty list of A2A parts"), + ("{'message': {}}", "a message is text or a list of A2A parts"), ("{'message': [{'kind': 'nope'}]}", "message part 0 is not an A2A Part"), ]) async def test_a_hook_returning_something_else_fails_the_step(monkeypatch, returned, fragment): @@ -522,7 +527,7 @@ async def post_turn(turn, params): @pytest.mark.asyncio @pytest.mark.parametrize("armed", [False, True]) -async def test_without_hooks_an_env_offering_a_clock_adds_clock_reads_and_nothing_else(monkeypatch, armed): +async def test_without_hooks_a_full_gateway_card_adds_clock_reads_and_empty_takes_and_nothing_else(monkeypatch, armed): real_client = httpx.AsyncClient def run(full_card: bool): @@ -544,8 +549,12 @@ def run(full_card: bool): poll_interval_seconds=0, max_conversation_turns=4).execute(ctx) requests = peers.requests(ctx) - assert [r for r in requests if r[1] != f"{ENV}/clock/state"] == base_peers.requests(base_ctx) + added = (f"{ENV}/clock/state", f"{ENV}/notifications/take") + assert [r for r in requests if r[1] not in added] == base_peers.requests(base_ctx) assert [r for r in requests if r[1] == f"{ENV}/clock/state"] == [("GET", f"{ENV}/clock/state", None, 5)] * 4 + # one take, after the turn the conversation went on from; the user sim's done ended the next one + assert [r for r in requests if r[1] == f"{ENV}/notifications/take"] == [ + ("POST", f"{ENV}/notifications/take", {"to": "default", "take_id": ""}, 30)] stored, base = _stored(ctx, peers)["messages"], _stored(base_ctx, base_peers)["messages"] stamp = [{"env_id": "env", "env_step_id": None, "virtual_time": TUESDAY}] assert [{k: v for k, v in m.items() if k != "env_clock"} for m in stored] == base diff --git a/tst/unit/task_step/test_prompt_agent_user_side.py b/tst/unit/task_step/test_prompt_agent_user_side.py index 55b759b60..0aba179a8 100644 --- a/tst/unit/task_step/test_prompt_agent_user_side.py +++ b/tst/unit/task_step/test_prompt_agent_user_side.py @@ -63,8 +63,8 @@ def requests(self, ctx: TaskStepContext) -> list[tuple]: return self._norm(self.raw) def _norm(self, value): - if isinstance(value, dict): - return {k: self._norm(v) for k, v in value.items()} + if isinstance(value, dict): # a notifications take_id is fresh per take, so it's named, not numbered + return {k: "" if k == "take_id" else self._norm(v) for k, v in value.items()} if isinstance(value, (list, tuple)): return type(value)(self._norm(v) for v in value) if isinstance(value, str) and _ID_RE.fullmatch(value): diff --git a/tst/unit/task_step/test_register_env_triggers.py b/tst/unit/task_step/test_register_env_triggers.py index 526f47bb2..1abba6959 100644 --- a/tst/unit/task_step/test_register_env_triggers.py +++ b/tst/unit/task_step/test_register_env_triggers.py @@ -578,3 +578,18 @@ def handle(request: httpx.Request) -> httpx.Response: monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **k: real(transport=httpx.MockTransport(handle))) return sent + + +@pytest.mark.asyncio +async def test_a_gateway_without_notify_refuses_it(monkeypatch): + sent = _mock_gateway(monkeypatch, {"ok": True, "added": ["n"], "all": ["n"]}) + notify = [{"id": "n", "when": {"type": "action", "tool": "t"}, + "actions": [{"type": "notify", "channel": "Gmail", "text": "New email"}]}] + context = TaskStepContext(deployed_envs=[_record(_with_features("named_executors", "provoking_roles"))], + deployed_agents=[], metadata={}) + with pytest.raises(RuntimeError, match=r"does not declare \['notify'\]"): + await _step(triggers=notify).execute(context) + assert sent == [] + context.deployed_envs = [_record()] + await _step(triggers=notify).execute(context) + assert json.loads(sent[0].content)["triggers"] == notify diff --git a/tst/unit/test_clean_install_wheel.py b/tst/unit/test_clean_install_wheel.py index 7390d6487..f964e907d 100644 --- a/tst/unit/test_clean_install_wheel.py +++ b/tst/unit/test_clean_install_wheel.py @@ -62,7 +62,9 @@ def test_bundle_entry_points_that_differ_from_pyproject_are_named(tmp_path, entr def test_the_gate_checks_the_folder_of_every_shipped_bundle(): roots = {name: root.relative_to(SCRIPT.parents[2]).as_posix() for name, root in clean_install.bundle_roots().items()} - assert roots == {"agents": "src/agent_env/agents", "computers": "src/agent_env/computers", - "desktop": "src/agent_env/examples/desktop", "drivers": "src/agent_env/drivers", "echo-mcp": "src/agent_env/examples/echo-mcp", - "hello": "src/agent_env/examples/hello", "mail-mcp": "src/agent_env/examples/mail-mcp", + assert roots == {"advance-to-next-event": "src/agent_env/examples/advance-to-next-event", + "agents": "src/agent_env/agents", "computers": "src/agent_env/computers", + "desktop": "src/agent_env/examples/desktop", "drivers": "src/agent_env/drivers", + "echo-mcp": "src/agent_env/examples/echo-mcp", "hello": "src/agent_env/examples/hello", + "mail-mcp": "src/agent_env/examples/mail-mcp", "next-env-event": "src/agent_env/examples/next-env-event"}