From e89a5399e6fb891758ccdd43282d3f221274f407 Mon Sep 17 00:00:00 2001 From: Sarah Chitty Date: Mon, 26 Oct 2026 23:54:13 -0700 Subject: [PATCH 01/14] =?UTF-8?q?feat(gateway):=20wakeups=20=E2=80=94=20ch?= =?UTF-8?q?ecks=20an=20agent=20schedules=20for=20itself,=20and=20notify=5F?= =?UTF-8?q?agent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What wakes an agent between its turns, on the env's virtual clock (urn:agentenv:wakeups/v1): - schedule_check / list_scheduled_checks / cancel_scheduled_check: gateway built-in tools, next to get_time, that exist only once a task turns them on (configure_wakeups step, or POST /wakeups/config). A check takes `at` (RFC 3339 or a duration from now) and/or `every` with `until`; max_active and min_interval cap them. - notify_agent: a trigger action that queues a notification ({channel, text}, text templated from the provoking call) at the current virtual time. Only task designers write it; the agent has no general alert tool. - GET /wakeups/state says when the next check is due and what is pending; POST /wakeups/take hands out each due check and notification once (a repeating check passed several times in one jump comes out once, with a count). Nothing here wakes anyone: a post-turn hook reads state, moves the clock and takes what is due. Opt-in throughout: without configure_wakeups no tool appears, and a gateway without notify_agent is refused by register_env_triggers before any request. Co-Authored-By: Claude Opus 5.5 --- src/agent_env/env/gateway/constants.py | 17 +- src/agent_env/env/gateway/gateway.py | 86 +++++- src/agent_env/env/gateway/triggers.py | 22 +- src/agent_env/env/gateway/wakeups.py | 286 +++++++++++++++++ src/agent_env/task/episode/rules.py | 3 +- src/agent_env/task_step/registry.py | 2 + .../task_step/task_steps/configure_wakeups.py | 90 ++++++ .../task_steps/register_env_triggers.py | 5 +- tst/unit/env/gateway/gateway_card_test.py | 4 +- tst/unit/env/gateway/wakeups_test.py | 289 ++++++++++++++++++ tst/unit/task_step/test_configure_wakeups.py | 79 +++++ .../task_step/test_register_env_triggers.py | 15 + 12 files changed, 890 insertions(+), 8 deletions(-) create mode 100644 src/agent_env/env/gateway/wakeups.py create mode 100644 src/agent_env/task_step/task_steps/configure_wakeups.py create mode 100644 tst/unit/env/gateway/wakeups_test.py create mode 100644 tst/unit/task_step/test_configure_wakeups.py diff --git a/src/agent_env/env/gateway/constants.py b/src/agent_env/env/gateway/constants.py index 4ab0bf77a..75348e187 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_WAKEUPS_URI = "urn:agentenv:wakeups/v1" # Rows per GET /changes page: the default, and the most a client may ask for. CHANGES_PAGE_DEFAULT = 1000 @@ -121,8 +122,9 @@ def sources(names: list[str]) -> str: TRIGGER_UNLESS = "unless" TRIGGER_WEBSITE = "website" TRIGGER_EXECUTOR_HOURS_TIME_ZONE = "executor_hours_time_zone" +TRIGGER_NOTIFY_AGENT = "notify_agent" TRIGGER_FEATURES = (TRIGGER_NAMED_EXECUTORS, TRIGGER_PROVOKING_ROLES, TRIGGER_ACTION_DELAY, TRIGGER_EXECUTOR_HOURS, - TRIGGER_UNLESS, TRIGGER_WEBSITE, TRIGGER_EXECUTOR_HOURS_TIME_ZONE) + TRIGGER_UNLESS, TRIGGER_WEBSITE, TRIGGER_EXECUTOR_HOURS_TIME_ZONE, TRIGGER_NOTIFY_AGENT) GATEWAY_TRAJECTORY_FILE = os.environ.get( "GATEWAY_TRAJECTORY_FILE", @@ -235,4 +237,17 @@ def random_mcp_server_name() -> str: "limit": {"type": "integer", "minimum": 1, "maximum": CHANGES_PAGE_MAX}}}}}}, "required": False, }, + { + "uri": EXT_WAKEUPS_URI, + "description": "Wakeups (urn:agentenv:wakeups/v1): what is due to wake the agent between its turns, on the virtual clock: checks it scheduled for itself (schedule_check, which config turns on) and notifications triggers sent it (the notify_agent action). state says when the next is due; take hands out each due one once (harness control path).", + "params": {"endpoint": "/wakeups/state", "methods": { + "config": {"method": "POST", "endpoint": "/wakeups/config", "request": {"type": "object", "properties": { + "scheduled_checks": {"type": "boolean"}, "max_active": {"type": "integer", "minimum": 1}, + "min_interval": {"type": "string"}}}}, + "state": {"method": "GET", "endpoint": "/wakeups/state", "request": {"type": "object", "properties": {}}}, + "take": {"method": "POST", "endpoint": "/wakeups/take", "request": {"type": "object", "properties": { + "until": {"type": "string"}}}}, + "clear": {"method": "POST", "endpoint": "/wakeups/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 a0d7af367..219e6541f 100644 --- a/src/agent_env/env/gateway/gateway.py +++ b/src/agent_env/env/gateway/gateway.py @@ -72,6 +72,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 .wakeups import WAKEUP_TOOL_NAMES, WakeupError, Wakeups, build_wakeup_tools logger = logging.getLogger(__name__) @@ -171,6 +172,7 @@ async def list_website_urls() -> list[str]: ) ) self._clock = Clock() + self._wakeups = Wakeups(lambda: self._clock) self._env_get_time_url_cache: str | None = None self._install_role_filters() @@ -298,6 +300,42 @@ 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 _wakeup_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 WakeupError as e: + return self._json_response({"ok": False, "error": str(e)}, 400) + + @self._mcp.custom_route("/wakeups/config", methods=["POST"]) + async def wakeups_config(request: Request) -> Response: + taken = sorted(set(WAKEUP_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) + response = await _wakeup_write(request, self._wakeups.configure) + if self._wakeups.enabled: + self._register_wakeup_tools() + else: + self._unregister_wakeup_tools() + return response + + @self._mcp.custom_route("/wakeups/state", methods=["GET"]) + async def wakeups_state(request: Request) -> Response: + return self._json_response(self._wakeups.state(), 200) + + @self._mcp.custom_route("/wakeups/take", methods=["POST"]) + async def wakeups_take(request: Request) -> Response: + return await _wakeup_write(request, self._wakeups.take) + + @self._mcp.custom_route("/wakeups/clear", methods=["POST"]) + async def wakeups_clear(request: Request) -> Response: + self._unregister_wakeup_tools() + return self._json_response(self._wakeups.clear(), 200) + @self._mcp.custom_route("/trajectory", methods=["GET"]) async def env_trajectory(request: Request) -> Response: return self._serve_trajectory() @@ -885,9 +923,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 wakeup tools are left out: a backing server's tool of that name wins, and the clock and + # wakeups 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 WAKEUP_TOOL_NAMES} for server, tools in listed: for tool in tools: sources.setdefault(tool.name, []).append(server.name) @@ -907,6 +947,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_wakeup_descriptors(): + logger.info("Re-mirrored the wakeup 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() @@ -1127,6 +1169,46 @@ def _unregister_get_time_tool(self) -> None: self._server_tools.pop("gateway", None) self._trigger_engine.invalidate_readonly_cache() + def _register_wakeup_tools(self) -> None: + """Expose schedule_check and its siblings (idempotent). Called when a task turns scheduled checks on.""" + tools = self._mcp._tool_manager._tools + for tool in build_wakeup_tools(self._wakeups, self._create_arg_model_from_schema): + if tool.name not in self._tool_server_urls: + tools.setdefault(tool.name, tool) + if self._mirror_wakeup_descriptors(): + self._trigger_engine.invalidate_readonly_cache() + + def _mirror_wakeup_descriptors(self) -> bool: + """Mirror the registered wakeup 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._wakeups.enabled: + return False + mirrored = self._server_tools.setdefault("gateway", []) + present = {t.name for t in mirrored} + added = False + for name in WAKEUP_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_wakeup_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 WAKEUP_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 WAKEUP_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/triggers.py b/src/agent_env/env/gateway/triggers.py index 8d4eb8817..bb2daa230 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 .wakeups import validate_channel 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_agent") _TERMINAL_A2A_STATES = ("completed", "failed", "canceled", "rejected") # Bounds the per-evaluation catch-up burst; the backlog drains on later evaluations. _MAX_CATCHUP = 1000 @@ -741,6 +742,8 @@ 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_agent": + raise TriggerError(f"{w}: a notify_agent action has nothing to verify") if action.get("delay") is None: action.pop("delay", None) else: @@ -760,6 +763,14 @@ 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_agent": + try: + validate_channel(action.get("channel"), 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") @@ -1317,6 +1328,15 @@ 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_agent": + try: + text = _template(action["text"], {}, ctx) + except TemplateError as e: + self._emit("action_failed", tid, action_index=index, detail=str(e)[:300]) + return False + note = self._gw._wakeups.notify(action["channel"], str(text), trigger_id=tid) + # the text may carry what a person wrote, so the log keeps its size, not its words + detail = {"notify_agent": action["channel"], "notification_id": note["id"], "text_len": len(str(text))} else: if not await self._run_nl(tid, trig, index, action, ctx): return False diff --git a/src/agent_env/env/gateway/wakeups.py b/src/agent_env/env/gateway/wakeups.py new file mode 100644 index 000000000..7872c137b --- /dev/null +++ b/src/agent_env/env/gateway/wakeups.py @@ -0,0 +1,286 @@ +"""What wakes an agent between its turns (urn:agentenv:wakeups/v1). + +Two sources, both on the gateway's virtual clock: +- checks the agent schedules for itself with the schedule_check / list_scheduled_checks / cancel_scheduled_check + tools, which exist only once a task turns them on (`configure`); +- notifications a task's triggers send it with the `notify_agent` action, through a channel the task declares. + +Nothing here wakes anyone: a harness (a post-turn hook) reads `state` to learn when the next one is due, moves the +clock there, and `take`s what has come due to put it in front of the agent. +""" +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 .clock import Clock, ClockError, _iso, _parse_duration, _parse_rfc3339 + +SCHEDULE_CHECK_TOOL_NAME = "schedule_check" +LIST_SCHEDULED_CHECKS_TOOL_NAME = "list_scheduled_checks" +CANCEL_SCHEDULED_CHECK_TOOL_NAME = "cancel_scheduled_check" +WAKEUP_TOOL_NAMES = (SCHEDULE_CHECK_TOOL_NAME, LIST_SCHEDULED_CHECKS_TOOL_NAME, CANCEL_SCHEDULED_CHECK_TOOL_NAME) + +DEFAULT_MAX_ACTIVE = 10 +DEFAULT_MIN_INTERVAL = "PT1H" +_MAX_NOTE_CHARS = 2000 +_MAX_CHANNEL_CHARS = 60 + +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." +) +_SCHEDULE_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "note": {"type": "string", "description": "What to do when the check comes due."}, + "at": {"type": "string", "description": "When: an RFC 3339 time with its offset (2027-02-02T18:00:00-08:00), " + "or an ISO 8601 duration from now (PT6H)."}, + "every": {"type": "string", "description": "Repeat this often: an ISO 8601 duration (PT6H, P1D)."}, + "until": {"type": "string", "description": "Stop repeating after this time (RFC 3339) or duration from now."}, + }, + "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 WakeupError(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: + """An RFC 3339 instant, or an ISO 8601 duration counted from now.""" + if not isinstance(text, str) or not text: + raise WakeupError(f"{field} must be an RFC 3339 time or an ISO 8601 duration") + try: + return now + _parse_duration(text) if text.startswith("P") else _parse_rfc3339(text) + except ClockError as e: + raise WakeupError(f"{field}: {e}") + + +class Wakeups: + """The gateway's record of scheduled checks and agent 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._enabled = False + self._max_active = DEFAULT_MAX_ACTIVE + self._min_interval = _parse_duration(DEFAULT_MIN_INTERVAL) + self._checks: dict[str, dict] = {} + self._notifications: list[dict] = [] + self._seq = 0 + + @property + def enabled(self) -> bool: + return self._enabled + + def configure(self, body: Any) -> dict: + """Turn scheduled checks on or off, with their limits. Notifications need no switch.""" + if not isinstance(body, dict): + raise WakeupError("body must be a JSON object") + enabled = body.get("scheduled_checks", True) + if not isinstance(enabled, bool): + raise WakeupError("scheduled_checks must be a boolean") + 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 WakeupError("max_active must be a positive integer") + try: + min_interval = _parse_duration(body.get("min_interval", DEFAULT_MIN_INTERVAL)) + except ClockError as e: + raise WakeupError(f"min_interval: {e}") + if min_interval <= timedelta(0): + raise WakeupError("min_interval must be a positive duration") + with self._lock: + self._enabled, self._max_active, self._min_interval = enabled, max_active, min_interval + return self.state() + + def clear(self) -> dict: + with self._lock: + self._enabled = False + self._checks.clear() + self._notifications.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 WakeupError("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) -> dict: + if not self._enabled: + raise WakeupError("scheduled checks are not available here") + if not isinstance(note, str) or not note.strip(): + raise WakeupError("note must say what to do when the check comes due") + if len(note) > _MAX_NOTE_CHARS: + raise WakeupError(f"note must be at most {_MAX_NOTE_CHARS} characters") + now = self._now() + step = None + if every is not None: + try: + step = _parse_duration(every) + except ClockError as e: + raise WakeupError(f"every: {e}") + if step < self._min_interval: + raise WakeupError(f"every must be at least {self._min_interval} apart") + first = _when(at, now, "at") if at is not None else (now + step if step else None) + if first is None: + raise WakeupError("give at (when) or every (how often)") + if first <= now: + raise WakeupError(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 WakeupError("until only applies with every") + if end is not None and end < first: + raise WakeupError("until is before the first check") + with self._lock: + if sum(c["status"] == "active" for c in self._checks.values()) >= self._max_active: + raise WakeupError(f"you already have {self._max_active} scheduled checks; cancel one first") + cid = self._next_id("check") + self._checks[cid] = {"check_id": cid, "note": note, "next": first, "every": step, "until": end, + "created_at": now, "status": "active", "fired": 0} + return self._public(self._checks[cid]) + + def list(self) -> dict: + with self._lock: + return {"checks": [self._public(c) for c in self._checks.values() if c["status"] == "active"]} + + def cancel(self, check_id: Any) -> dict: + with self._lock: + check = self._checks.get(check_id) if isinstance(check_id, str) else None + if check is None or check["status"] != "active": + raise WakeupError(f"no active scheduled check {check_id!r}") + check["status"] = "cancelled" + 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"] = str(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) -> dict: + """Queue a notification for the agent at the current virtual time (wall time if the clock is unset).""" + now = self._clock.now() + with self._lock: + item = {"id": self._next_id("note"), "channel": channel, "text": text, "trigger_id": trigger_id, + "at": _iso(now) if now else None, "status": "pending"} + self._notifications.append(item) + return dict(item) + + # ---- harness ---- + + def state(self) -> dict: + with self._lock: + active = [c for c in self._checks.values() if c["status"] == "active"] + pending = [dict(n) for n in self._notifications if n["status"] == "pending"] + nxt = min((c["next"] for c in active), default=None) + return {"scheduled_checks": self._enabled, "max_active": self._max_active, + "min_interval": str(self._min_interval), "next_check_at": _iso(nxt) if nxt else None, + "checks": [{**self._public(c), "status": c["status"], "fired": c["fired"]} + for c in self._checks.values()], + "pending_notifications": pending} + + def take(self, body: Any = None) -> dict: + """Everything due by `until` (default: now), oldest first; each is handed out once. A repeating check moves to + its next time after `until` and says how many times it came due, so a long jump wakes the agent once.""" + body = body if isinstance(body, dict) else {} + now = self._clock.now() + until = _when(body["until"], now, "until") if body.get("until") is not None and now else now + items: list[dict] = [] + with self._lock: + for check in self._checks.values(): + if check["status"] != "active" or until is None or check["next"] > until: + continue + due, times = check["next"], 0 + while check["status"] == "active" and check["next"] <= until: + times += 1 + if check["every"] and (check["until"] is None or check["next"] + check["every"] <= check["until"]): + check["next"] = check["next"] + check["every"] + else: + check["status"] = "done" + check["fired"] += 1 + items.append({"kind": "scheduled_check", "check_id": check["check_id"], "note": check["note"], + "at": _iso(due), "times": times}) + for note in self._notifications: + if note["status"] == "pending" and (until is None or note["at"] is None or note["at"] <= _iso(until)): + note["status"] = "delivered" + items.append({"kind": "notification", "id": note["id"], "channel": note["channel"], + "text": note["text"], "at": note["at"], "trigger_id": note["trigger_id"]}) + items.sort(key=lambda i: i["at"] or "") + return {"items": items, "until": _iso(until) if until else None} + + +def _tool_error(text: str) -> CallToolResult: + return CallToolResult(isError=True, content=[TextContent(type="text", text=text)]) + + +def build_wakeup_tools(wakeups: Wakeups, 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.""" + + async def schedule_check(note: str, at: str | None = None, every: str | None = None, + until: str | None = None) -> dict | CallToolResult: + try: + return wakeups.schedule(note, at=at, every=every, until=until) + except WakeupError as e: + return _tool_error(str(e)) + + async def list_scheduled_checks() -> dict: + return wakeups.list() + + async def cancel_scheduled_check(check_id: str) -> dict | CallToolResult: + try: + return wakeups.cancel(check_id) + except WakeupError as e: + return _tool_error(str(e)) + + specs = [ + (SCHEDULE_CHECK_TOOL_NAME, schedule_check, SCHEDULE_CHECK_DESCRIPTION, _SCHEDULE_SCHEMA, 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_channel(channel: Any, where: str) -> None: + """A notify_agent 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") diff --git a/src/agent_env/task/episode/rules.py b/src/agent_env/task/episode/rules.py index 5090f6efe..937bfc9de 100644 --- a/src/agent_env/task/episode/rules.py +++ b/src/agent_env/task/episode/rules.py @@ -147,7 +147,8 @@ 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", + "aggregate_verifiers", "apply_server_config", "build_mcp_cli", "combine_universe_verdicts", "configure_wakeups", + "deploy_env", "deploy_sandbox", "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", diff --git a/src/agent_env/task_step/registry.py b/src/agent_env/task_step/registry.py index 76d1934c0..8180e4be3 100644 --- a/src/agent_env/task_step/registry.py +++ b/src/agent_env/task_step/registry.py @@ -42,6 +42,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_wakeups import ConfigureWakeupsTaskStep 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 @@ -94,6 +95,7 @@ def _builtin_registry() -> dict[str, type["TaskStep"]]: _get_type(RegisterEnvTriggersStep): RegisterEnvTriggersStep, _get_type(RegisterAgentTriggersStep): RegisterAgentTriggersStep, _get_type(SyncEnvClockTaskStep): SyncEnvClockTaskStep, + _get_type(ConfigureWakeupsTaskStep): ConfigureWakeupsTaskStep, _get_type(InstallAgentTaskStep): InstallAgentTaskStep, _get_type(RubricsVerifierTaskStep): RubricsVerifierTaskStep, _get_type(RunContainerUnitTestsVerifierTaskStep): RunContainerUnitTestsVerifierTaskStep, diff --git a/src/agent_env/task_step/task_steps/configure_wakeups.py b/src/agent_env/task_step/task_steps/configure_wakeups.py new file mode 100644 index 000000000..0700c37f2 --- /dev/null +++ b/src/agent_env/task_step/task_steps/configure_wakeups.py @@ -0,0 +1,90 @@ +"""Task step that turns on the checks an agent can schedule for itself on a deployed env's gateway (wakeups/v1). + +With it the agent gets schedule_check, list_scheduled_checks and cancel_scheduled_check, on the env's virtual clock. +Place it after sync_env_clock (a check needs the clock) and before prompt_agent (the agent needs the tools on its +first turn). A post-turn hook reads the gateway's wakeups state to wake the agent when a check comes due. +""" + +from __future__ import annotations + +import logging +from typing import ClassVar, Optional + +import httpx + +from agent_env.env.gateway.constants import EXT_WAKEUPS_URI +from agent_env.env.gateway.wakeups 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 ConfigureWakeupsTaskStep(TaskStep): + type: ClassVar[str] = "configure_wakeups" + entity_refs = (EntityRef.env("env_id"),) + + def __init__( + self, + id: str, + version: Optional[int], + env_id: str, + scheduled_checks: bool = True, + 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, + ): + """max_active caps the agent'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(scheduled_checks, bool): + raise ValueError("scheduled_checks must be a boolean") + 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.scheduled_checks = scheduled_checks + 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["scheduled_checks"] = self.scheduled_checks + 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) -> ConfigureWakeupsTaskStep: + return cls( + **cls._base_from_dict(data), + env_id=data["env_id"], + scheduled_checks=data.get("scheduled_checks", True), + 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_WAKEUPS_URI, "config"): + raise RuntimeError(f"Env '{self.env_id}' runs a gateway without {EXT_WAKEUPS_URI}; redeploy it on a gateway " + "image that has it.") + body = {"scheduled_checks": self.scheduled_checks, "max_active": self.max_active, + "min_interval": self.min_interval} + try: + state = await deployed.invoke(EXT_WAKEUPS_URI, "config", body, timeout=self.timeout_seconds) + except httpx.HTTPStatusError as e: + raise RuntimeError(f"wakeups config failed (HTTP {e.response.status_code}): {e.response.text}") from e + logger.info(f"configure_wakeups env={self.env_id}: {body}") + context.metadata.setdefault("wakeup_configurations", []).append({ + "step_id": self.id, "env_id": self.env_id, **body, + "applied": {k: state.get(k) for k in ("scheduled_checks", "max_active", "min_interval")}, + }) + return context 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..b36970ded 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_AGENT, 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_AGENT: any(a.get("type") == "notify_agent" 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/unit/env/gateway/gateway_card_test.py b/tst/unit/env/gateway/gateway_card_test.py index 05b06ce69..704cc02c2 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:wakeups/v1", ] diff --git a/tst/unit/env/gateway/wakeups_test.py b/tst/unit/env/gateway/wakeups_test.py new file mode 100644 index 000000000..85f2c35b8 --- /dev/null +++ b/tst/unit/env/gateway/wakeups_test.py @@ -0,0 +1,289 @@ +"""Unit tests for what wakes an agent between turns (urn:agentenv:wakeups/v1): scheduled checks and notify_agent.""" +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.clock import Clock +from agent_env.env.gateway.gateway import Gateway +from agent_env.env.gateway.triggers import TriggerEngine, TriggerError +from agent_env.env.gateway.wakeups import WAKEUP_TOOL_NAMES, WakeupError, Wakeups + +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 _wakeups(clock: Clock | None = None, **config) -> Wakeups: + clock = clock or _frozen_clock() + w = Wakeups(lambda: clock) + w.configure({"scheduled_checks": True, **config}) + return w + + +# ---- Wakeups ---- + +def test_scheduled_checks_are_off_until_a_task_turns_them_on(): + w = Wakeups(lambda: _frozen_clock()) + assert w.state()["scheduled_checks"] is False + with pytest.raises(WakeupError, 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 = _wakeups() + 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"] + + +@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"}, "RFC 3339|RFC3339"), + ({"note": "x", "every": "PT10M"}, "at least"), + ({"note": "x", "at": "PT1H", "until": "P1D"}, "until only applies"), + ({"note": "x", "every": "PT6H", "until": "PT1H"}, "before the first"), +]) +def test_bad_checks_are_refused_with_the_reason(kwargs, error): + with pytest.raises(WakeupError, match=error): + _wakeups().schedule(**kwargs) + + +def test_the_active_cap_refuses_one_more_until_one_is_cancelled(): + w = _wakeups(max_active=2) + first = w.schedule("a", at="PT1H")["check_id"] + w.schedule("b", at="PT2H") + with pytest.raises(WakeupError, match="already have 2"): + w.schedule("c", at="PT3H") + w.cancel(first) + w.schedule("c", at="PT3H") + with pytest.raises(WakeupError, 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 = _wakeups(clock) + w.schedule("look at 9", at="PT2H") + w.schedule("look at noon", at="PT5H") + assert w.state()["next_check_at"] == "2027-02-01T17:00:00Z" + clock.set_time("2027-02-01T17:00:00Z", 0) + taken = w.take({})["items"] + assert [(i["kind"], i["note"], 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_check_at"] == "2027-02-01T20:00:00Z" + + +def test_a_repeating_check_moves_past_a_long_jump_and_says_how_often_it_came_due(): + clock = _frozen_clock() + w = _wakeups(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_repeating_check_ends_at_until(): + clock = _frozen_clock() + w = _wakeups(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_check_at"] is None + + +def test_take_until_looks_ahead_without_moving_the_clock(): + w = _wakeups() + w.schedule("later", at="PT3H") + assert w.take({"until": "PT1H"})["items"] == [] + assert [i["note"] for i in w.take({"until": "PT3H"})["items"]] == ["later"] + + +def test_notifications_queue_at_the_virtual_time_and_are_taken_once(): + clock = _frozen_clock() + w = Wakeups(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["kind"], item["text"]) == ("notification", "Jamie: did the rug thing get sorted?") + assert w.take({})["items"] == [] and w.state()["pending_notifications"] == [] + + +def test_clear_turns_checks_off_and_forgets_everything(): + w = _wakeups() + w.schedule("x", at="PT1H") + w.notify("Gmail", "hello") + w.clear() + st = w.state() + assert (st["scheduled_checks"], st["checks"], st["pending_notifications"]) == (False, [], []) + + +@pytest.mark.parametrize("body, error", [ + ({"scheduled_checks": "yes"}, "boolean"), + ({"max_active": 0}, "positive integer"), + ({"min_interval": "1h"}, "min_interval"), +]) +def test_bad_config_is_refused(body, error): + with pytest.raises(WakeupError, match=error): + Wakeups(lambda: _frozen_clock()).configure(body) + + +# ---- 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) -> 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) + + 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_tools_appear_only_once_a_task_turns_them_on_and_go_on_clear(gateway): + tools = gateway._mcp._tool_manager._tools + assert not set(WAKEUP_TOOL_NAMES) & set(tools) + r = _request(gateway, "POST", "/wakeups/config", {"max_active": 5}) + assert r.status_code == 200 and r.json()["max_active"] == 5 + assert set(WAKEUP_TOOL_NAMES) <= set(tools) and set(WAKEUP_TOOL_NAMES) <= _gateway_tool_names(gateway) + assert _request(gateway, "POST", "/wakeups/clear", {}).status_code == 200 + assert not set(WAKEUP_TOOL_NAMES) & set(tools) and not set(WAKEUP_TOOL_NAMES) & _gateway_tool_names(gateway) + + +def test_turning_checks_off_removes_the_tools(gateway): + _request(gateway, "POST", "/wakeups/config", {}) + _request(gateway, "POST", "/wakeups/config", {"scheduled_checks": False}) + assert not set(WAKEUP_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", "/wakeups/config", {}) + assert r.status_code == 409 and "schedule_check" in r.json()["error"] + + +def test_a_bad_config_is_a_400(gateway): + assert _request(gateway, "POST", "/wakeups/config", {"max_active": -1}).status_code == 400 + + +def test_the_agent_schedules_through_the_tool_and_the_harness_takes_it(gateway): + _request(gateway, "POST", "/wakeups/config", {}) + tool = gateway._mcp._tool_manager._tools["schedule_check"] + made = asyncio.run(tool.fn(note="check rugs", at="PT2H")) + assert made["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", "/wakeups/state").json() + assert state["next_check_at"] == "2027-02-01T17:00:00Z" + gateway._clock.set_time("2027-02-01T17:00:00Z", 0) + items = _request(gateway, "POST", "/wakeups/take", {}).json()["items"] + assert [i["note"] for i in items] == ["check rugs"] + + +def test_list_is_read_only_so_it_does_not_re_check_state_triggers(gateway): + _request(gateway, "POST", "/wakeups/config", {}) + 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 + + +# ---- notify_agent ---- + +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._wakeups = Wakeups(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_agent", "channel": "Messages", "text": "Jamie: ok", **action}]} + + +@pytest.mark.asyncio +async def test_notify_agent_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._wakeups.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_agent"] == "Messages" and "got your text" not in json.dumps(ok[0]) + + +@pytest.mark.parametrize("action, error", [ + ({"channel": ""}, "channel"), + ({"text": " "}, "text"), + ({"verify": {"tool": "x", "predicate": {"exists": True}}}, "nothing to verify"), + ({"text": "${result.id}", "channel": "Gmail"}, None), +]) +def test_notify_agent_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) diff --git a/tst/unit/task_step/test_configure_wakeups.py b/tst/unit/task_step/test_configure_wakeups.py new file mode 100644 index 000000000..ff04738f1 --- /dev/null +++ b/tst/unit/task_step/test_configure_wakeups.py @@ -0,0 +1,79 @@ +"""ConfigureWakeupsTaskStep posts the env's wakeups/v1 config as the stored card declares it and records it, and +refuses before any request on a gateway without wakeups/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_WAKEUPS_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_wakeups import ConfigureWakeupsTaskStep + +_GATEWAY = "http://gw:18765" + + +@pytest.mark.asyncio +async def test_posts_the_config_and_records_what_the_gateway_applied(monkeypatch): + sent = _mock_gateway(monkeypatch, {"scheduled_checks": True, "max_active": 5, "min_interval": "1:00:00"}) + out = await _step(max_active=5).execute(TaskStepContext(deployed_envs=[_record()], metadata={})) + [request] = sent + assert (request.method, str(request.url)) == ("POST", f"{_GATEWAY}/wakeups/config") + assert json.loads(request.content) == {"scheduled_checks": True, "max_active": 5, "min_interval": "PT1H"} + [entry] = out.metadata["wakeup_configurations"] + assert entry["applied"] == {"scheduled_checks": True, "max_active": 5, "min_interval": "1:00:00"} + + +@pytest.mark.asyncio +async def test_a_gateway_without_wakeups_refuses_before_any_request(monkeypatch): + sent = _mock_gateway(monkeypatch, {}) + older = [e for e in GATEWAY_EXTENSIONS if e["uri"] != EXT_WAKEUPS_URI] + with pytest.raises(RuntimeError, match="without urn:agentenv:wakeups/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 = ConfigureWakeupsTaskStep.from_dict(step.to_dict()) + assert (again.max_active, again.min_interval, again.scheduled_checks) == (3, "PT2H", True) + assert get_task_step_registry()["configure_wakeups"] is ConfigureWakeupsTaskStep + + +@pytest.mark.parametrize("kwargs", [{"max_active": 0}, {"scheduled_checks": "yes"}]) +def test_bad_settings_are_refused_when_the_step_is_built(kwargs): + with pytest.raises(ValueError): + _step(**kwargs) + + +def _step(**kwargs) -> ConfigureWakeupsTaskStep: + return ConfigureWakeupsTaskStep(id="wakeups-1", version=None, env_id="env-x", **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_register_env_triggers.py b/tst/unit/task_step/test_register_env_triggers.py index 526f47bb2..7c7c533b7 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_agent_refuses_it(monkeypatch): + sent = _mock_gateway(monkeypatch, {"ok": True, "added": ["n"], "all": ["n"]}) + notify = [{"id": "n", "when": {"type": "action", "tool": "t"}, + "actions": [{"type": "notify_agent", "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_agent'\]"): + 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 From 8f9f624b577a534ba7adfb7967eee3461bfa0731 Mon Sep 17 00:00:00 2001 From: Sarah Chitty Date: Tue, 27 Oct 2026 10:07:37 -0700 Subject: [PATCH 02/14] =?UTF-8?q?fix(gateway):=20wakeups=20=E2=80=94=20cou?= =?UTF-8?q?nt=20repeats=20instead=20of=20stepping,=20judge=20due=20on=20ti?= =?UTF-8?q?mes,=20wait=20for=20discovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - take counts how often a repeating check came due and its next time directly, so a long jump over a short every costs nothing, and works the whole batch out before marking anything handed out - schedule refuses an every (or at) too long to count forward, which would otherwise break every later take - notifications are due by their parsed time, not their text (fractional seconds sorted wrong) - /wakeups/config finishes tool discovery before its name check (503 if discovery fails), so a later discovery can't replace schedule_check unseen - configure_wakeups logs a one-line completion, not the request body Co-Authored-By: Claude Opus 5.5 --- src/agent_env/env/gateway/gateway.py | 5 ++ src/agent_env/env/gateway/wakeups.py | 80 ++++++++++++------- .../task_step/task_steps/configure_wakeups.py | 2 +- tst/unit/env/gateway/wakeups_test.py | 43 ++++++++++ 4 files changed, 102 insertions(+), 28 deletions(-) diff --git a/src/agent_env/env/gateway/gateway.py b/src/agent_env/env/gateway/gateway.py index 219e6541f..c8c4a517e 100644 --- a/src/agent_env/env/gateway/gateway.py +++ b/src/agent_env/env/gateway/gateway.py @@ -312,6 +312,11 @@ async def _wakeup_write(request: Request, method) -> Response: @self._mcp.custom_route("/wakeups/config", methods=["POST"]) async def wakeups_config(request: Request) -> Response: + try: # the backing servers' tools must be known, or a later discovery would replace ours unseen + 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(WAKEUP_TOOL_NAMES) & set(self._tool_server_urls)) if taken: return self._json_response( diff --git a/src/agent_env/env/gateway/wakeups.py b/src/agent_env/env/gateway/wakeups.py index 7872c137b..eacb36167 100644 --- a/src/agent_env/env/gateway/wakeups.py +++ b/src/agent_env/env/gateway/wakeups.py @@ -70,6 +70,8 @@ def _when(text: Any, now: datetime, field: str) -> datetime: return now + _parse_duration(text) if text.startswith("P") else _parse_rfc3339(text) except ClockError as e: raise WakeupError(f"{field}: {e}") + except OverflowError: + raise WakeupError(f"{field} is too far away") class Wakeups: @@ -102,7 +104,7 @@ def configure(self, body: Any) -> dict: raise WakeupError("max_active must be a positive integer") try: min_interval = _parse_duration(body.get("min_interval", DEFAULT_MIN_INTERVAL)) - except ClockError as e: + except (ClockError, OverflowError) as e: raise WakeupError(f"min_interval: {e}") if min_interval <= timedelta(0): raise WakeupError("min_interval must be a positive duration") @@ -145,13 +147,18 @@ def schedule(self, note: Any, at: Any = None, every: Any = None, until: Any = No if every is not None: try: step = _parse_duration(every) - except ClockError as e: + except (ClockError, OverflowError) as e: raise WakeupError(f"every: {e}") if step < self._min_interval: raise WakeupError(f"every must be at least {self._min_interval} apart") - first = _when(at, now, "at") if at is not None else (now + step if step else None) - if first is None: + if at is None and step is None: raise WakeupError("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 WakeupError("every is too long") if first <= now: raise WakeupError(f"at {_iso(first)} has already passed; it is now {_iso(now)}") end = _when(until, now, "until") if until is not None else None @@ -195,16 +202,16 @@ def notify(self, channel: str, text: str, trigger_id: str | None = None) -> dict now = self._clock.now() with self._lock: item = {"id": self._next_id("note"), "channel": channel, "text": text, "trigger_id": trigger_id, - "at": _iso(now) if now else None, "status": "pending"} + "at": _iso(now) if now else None, "status": "pending", "_at": now} self._notifications.append(item) - return dict(item) + return _public_note(item) # ---- harness ---- def state(self) -> dict: with self._lock: active = [c for c in self._checks.values() if c["status"] == "active"] - pending = [dict(n) for n in self._notifications if n["status"] == "pending"] + pending = [_public_note(n) for n in self._notifications if n["status"] == "pending"] nxt = min((c["next"] for c in active), default=None) return {"scheduled_checks": self._enabled, "max_active": self._max_active, "min_interval": str(self._min_interval), "next_check_at": _iso(nxt) if nxt else None, @@ -218,28 +225,47 @@ def take(self, body: Any = None) -> dict: body = body if isinstance(body, dict) else {} now = self._clock.now() until = _when(body["until"], now, "until") if body.get("until") is not None and now else now - items: list[dict] = [] + due: list[tuple[datetime | None, dict]] = [] with self._lock: - for check in self._checks.values(): - if check["status"] != "active" or until is None or check["next"] > until: - continue - due, times = check["next"], 0 - while check["status"] == "active" and check["next"] <= until: - times += 1 - if check["every"] and (check["until"] is None or check["next"] + check["every"] <= check["until"]): - check["next"] = check["next"] + check["every"] - else: - check["status"] = "done" + # 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 until is not None and c["next"] <= until] + notes = [n for n in self._notifications + if n["status"] == "pending" and (until is None or n["_at"] is None or n["_at"] <= until)] + for check, times, nxt in checks: + due.append((check["next"], {"kind": "scheduled_check", "check_id": check["check_id"], + "note": check["note"], "at": _iso(check["next"]), "times": times})) check["fired"] += 1 - items.append({"kind": "scheduled_check", "check_id": check["check_id"], "note": check["note"], - "at": _iso(due), "times": times}) - for note in self._notifications: - if note["status"] == "pending" and (until is None or note["at"] is None or note["at"] <= _iso(until)): - note["status"] = "delivered" - items.append({"kind": "notification", "id": note["id"], "channel": note["channel"], - "text": note["text"], "at": note["at"], "trigger_id": note["trigger_id"]}) - items.sort(key=lambda i: i["at"] or "") - return {"items": items, "until": _iso(until) if until else None} + if nxt is None: + check["status"] = "done" + else: + check["next"] = nxt + for note in notes: + note["status"] = "delivered" + due.append((note["_at"], {"kind": "notification", "id": note["id"], "channel": note["channel"], + "text": note["text"], "at": note["at"], "trigger_id": note["trigger_id"]})) + due.sort(key=lambda d: (d[0] is not None, d[0])) + return {"items": [item for _, item in due], "until": _iso(until) if until else None} + + +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 _public_note(note: dict) -> dict: + return {k: v for k, v in note.items() if not k.startswith("_")} def _tool_error(text: str) -> CallToolResult: diff --git a/src/agent_env/task_step/task_steps/configure_wakeups.py b/src/agent_env/task_step/task_steps/configure_wakeups.py index 0700c37f2..b66a2506f 100644 --- a/src/agent_env/task_step/task_steps/configure_wakeups.py +++ b/src/agent_env/task_step/task_steps/configure_wakeups.py @@ -82,7 +82,7 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: state = await deployed.invoke(EXT_WAKEUPS_URI, "config", body, timeout=self.timeout_seconds) except httpx.HTTPStatusError as e: raise RuntimeError(f"wakeups config failed (HTTP {e.response.status_code}): {e.response.text}") from e - logger.info(f"configure_wakeups env={self.env_id}: {body}") + logger.info(f"configure_wakeups completed for env={self.env_id}") context.metadata.setdefault("wakeup_configurations", []).append({ "step_id": self.id, "env_id": self.env_id, **body, "applied": {k: state.get(k) for k in ("scheduled_checks", "max_active", "min_interval")}, diff --git a/tst/unit/env/gateway/wakeups_test.py b/tst/unit/env/gateway/wakeups_test.py index 85f2c35b8..2d9134bd2 100644 --- a/tst/unit/env/gateway/wakeups_test.py +++ b/tst/unit/env/gateway/wakeups_test.py @@ -66,6 +66,8 @@ def test_a_check_takes_a_duration_from_now_or_a_time_with_its_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(WakeupError, match=error): @@ -108,6 +110,16 @@ def test_a_repeating_check_moves_past_a_long_jump_and_says_how_often_it_came_due 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 = _wakeups(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 = _wakeups(clock) @@ -135,6 +147,15 @@ def test_notifications_queue_at_the_virtual_time_and_are_taken_once(): assert w.take({})["items"] == [] and w.state()["pending_notifications"] == [] +def test_due_is_judged_on_the_time_not_its_text(): + clock = _frozen_clock() + w = _wakeups(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 [i.get("text") or i.get("note") for i in items] == ["on the second", "half a second on"] + + def test_clear_turns_checks_off_and_forgets_everything(): w = _wakeups() w.schedule("x", at="PT1H") @@ -202,6 +223,28 @@ def test_config_is_refused_over_a_backing_server_tool_of_the_same_name(gateway): assert r.status_code == 409 and "schedule_check" in r.json()["error"] +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", "/wakeups/config", {}) + 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", "/wakeups/config", {}) + assert r.status_code == 503 and "retry" in r.json()["error"] + assert not set(WAKEUP_TOOL_NAMES) & set(gateway._mcp._tool_manager._tools) + + def test_a_bad_config_is_a_400(gateway): assert _request(gateway, "POST", "/wakeups/config", {"max_active": -1}).status_code == 400 From 0878659b07cf2728beeb3fdaa25ffd696471432f Mon Sep 17 00:00:00 2001 From: Sarah Chitty Date: Tue, 27 Oct 2026 13:37:42 -0700 Subject: [PATCH 03/14] fix(gateway): wakeups echo durations as ISO 8601 (PT3H), the form the tools and config take The state, list_scheduled_checks and the too-short error showed Python's timedelta text (3:00:00), which /wakeups/config then refused when sent back. Co-Authored-By: Claude Opus 5.5 --- src/agent_env/env/gateway/wakeups.py | 15 ++++++++++++--- tst/unit/env/gateway/wakeups_test.py | 12 ++++++++++++ tst/unit/task_step/test_configure_wakeups.py | 4 ++-- 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/src/agent_env/env/gateway/wakeups.py b/src/agent_env/env/gateway/wakeups.py index eacb36167..7f0b5697f 100644 --- a/src/agent_env/env/gateway/wakeups.py +++ b/src/agent_env/env/gateway/wakeups.py @@ -150,7 +150,7 @@ def schedule(self, note: Any, at: Any = None, every: Any = None, until: Any = No except (ClockError, OverflowError) as e: raise WakeupError(f"every: {e}") if step < self._min_interval: - raise WakeupError(f"every must be at least {self._min_interval} apart") + raise WakeupError(f"every must be at least {_iso_duration(self._min_interval)} apart") if at is None and step is None: raise WakeupError("give at (when) or every (how often)") first = _when(at, now, "at") if at is not None else _when(every, now, "every") @@ -190,7 +190,7 @@ def cancel(self, check_id: Any) -> dict: def _public(check: dict) -> dict: out = {"check_id": check["check_id"], "note": check["note"], "next": _iso(check["next"])} if check["every"]: - out["every"] = str(check["every"]) + out["every"] = _iso_duration(check["every"]) if check["until"]: out["until"] = _iso(check["until"]) return out @@ -214,7 +214,7 @@ def state(self) -> dict: pending = [_public_note(n) for n in self._notifications if n["status"] == "pending"] nxt = min((c["next"] for c in active), default=None) return {"scheduled_checks": self._enabled, "max_active": self._max_active, - "min_interval": str(self._min_interval), "next_check_at": _iso(nxt) if nxt else None, + "min_interval": _iso_duration(self._min_interval), "next_check_at": _iso(nxt) if nxt else None, "checks": [{**self._public(c), "status": c["status"], "fired": c["fired"]} for c in self._checks.values()], "pending_notifications": pending} @@ -264,6 +264,15 @@ def _after(check: dict, until: datetime) -> tuple[int, datetime | 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 _public_note(note: dict) -> dict: return {k: v for k, v in note.items() if not k.startswith("_")} diff --git a/tst/unit/env/gateway/wakeups_test.py b/tst/unit/env/gateway/wakeups_test.py index 2d9134bd2..020051b64 100644 --- a/tst/unit/env/gateway/wakeups_test.py +++ b/tst/unit/env/gateway/wakeups_test.py @@ -74,6 +74,18 @@ def test_bad_checks_are_refused_with_the_reason(kwargs, error): _wakeups().schedule(**kwargs) +@pytest.mark.parametrize("text", ["PT3H", "P1D", "P1DT6H30M", "PT0.001S", "PT1M0.5S"]) +def test_durations_are_echoed_in_the_form_the_tools_take(text): + w = _wakeups(min_interval="PT0.001S") + assert w.schedule("x", every=text)["every"] == text + assert _wakeups(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(WakeupError, match=r"at least PT3H apart"): + _wakeups(min_interval="PT3H").schedule("x", every="PT1H") + + def test_the_active_cap_refuses_one_more_until_one_is_cancelled(): w = _wakeups(max_active=2) first = w.schedule("a", at="PT1H")["check_id"] diff --git a/tst/unit/task_step/test_configure_wakeups.py b/tst/unit/task_step/test_configure_wakeups.py index ff04738f1..1ab1941a4 100644 --- a/tst/unit/task_step/test_configure_wakeups.py +++ b/tst/unit/task_step/test_configure_wakeups.py @@ -19,13 +19,13 @@ @pytest.mark.asyncio async def test_posts_the_config_and_records_what_the_gateway_applied(monkeypatch): - sent = _mock_gateway(monkeypatch, {"scheduled_checks": True, "max_active": 5, "min_interval": "1:00:00"}) + sent = _mock_gateway(monkeypatch, {"scheduled_checks": True, "max_active": 5, "min_interval": "PT1H"}) out = await _step(max_active=5).execute(TaskStepContext(deployed_envs=[_record()], metadata={})) [request] = sent assert (request.method, str(request.url)) == ("POST", f"{_GATEWAY}/wakeups/config") assert json.loads(request.content) == {"scheduled_checks": True, "max_active": 5, "min_interval": "PT1H"} [entry] = out.metadata["wakeup_configurations"] - assert entry["applied"] == {"scheduled_checks": True, "max_active": 5, "min_interval": "1:00:00"} + assert entry["applied"] == {"scheduled_checks": True, "max_active": 5, "min_interval": "PT1H"} @pytest.mark.asyncio From 0965e7c6f4d027d2623c3bf0686398a390fd9de4 Mon Sep 17 00:00:00 2001 From: Sarah Chitty Date: Tue, 27 Oct 2026 15:02:45 -0700 Subject: [PATCH 04/14] =?UTF-8?q?fix(gateway):=20wakeups=20=E2=80=94=20tur?= =?UTF-8?q?ning=20checks=20off=20cancels=20the=20pending=20ones;=20history?= =?UTF-8?q?=20is=20bounded?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - configure with scheduled_checks false cancels the active checks, since the agent no longer has the tool to - delivered notifications are dropped, and state keeps only the last 50 finished or cancelled checks Co-Authored-By: Claude Opus 5.5 --- src/agent_env/env/gateway/wakeups.py | 15 +++++++++++++++ tst/unit/env/gateway/wakeups_test.py | 22 ++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/src/agent_env/env/gateway/wakeups.py b/src/agent_env/env/gateway/wakeups.py index 7f0b5697f..7bc045499 100644 --- a/src/agent_env/env/gateway/wakeups.py +++ b/src/agent_env/env/gateway/wakeups.py @@ -29,6 +29,7 @@ DEFAULT_MIN_INTERVAL = "PT1H" _MAX_NOTE_CHARS = 2000 _MAX_CHANNEL_CHARS = 60 +_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 " @@ -110,6 +111,11 @@ def configure(self, body: Any) -> dict: raise WakeupError("min_interval must be a positive duration") with self._lock: self._enabled, self._max_active, self._min_interval = enabled, max_active, min_interval + if not enabled: # the agent loses its tools, so the checks it can no longer cancel go too + for check in self._checks.values(): + if check["status"] == "active": + check["status"] = "cancelled" + self._prune() return self.state() def clear(self) -> dict: @@ -184,6 +190,7 @@ def cancel(self, check_id: Any) -> dict: if check is None or check["status"] != "active": raise WakeupError(f"no active scheduled check {check_id!r}") check["status"] = "cancelled" + self._prune() return {"cancelled": check_id} @staticmethod @@ -219,6 +226,13 @@ def state(self) -> dict: for c in self._checks.values()], "pending_notifications": pending} + 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._notifications = [n for n in self._notifications if n["status"] == "pending"] + def take(self, body: Any = None) -> dict: """Everything due by `until` (default: now), oldest first; each is handed out once. A repeating check moves to its next time after `until` and says how many times it came due, so a long jump wakes the agent once.""" @@ -244,6 +258,7 @@ def take(self, body: Any = None) -> dict: note["status"] = "delivered" due.append((note["_at"], {"kind": "notification", "id": note["id"], "channel": note["channel"], "text": note["text"], "at": note["at"], "trigger_id": note["trigger_id"]})) + self._prune() due.sort(key=lambda d: (d[0] is not None, d[0])) return {"items": [item for _, item in due], "until": _iso(until) if until else None} diff --git a/tst/unit/env/gateway/wakeups_test.py b/tst/unit/env/gateway/wakeups_test.py index 020051b64..be0a13db7 100644 --- a/tst/unit/env/gateway/wakeups_test.py +++ b/tst/unit/env/gateway/wakeups_test.py @@ -168,6 +168,28 @@ def test_due_is_judged_on_the_time_not_its_text(): assert [i.get("text") or i.get("note") 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 = _wakeups(clock) + w.schedule("sweep", every="P1D") + w.configure({"scheduled_checks": False}) + clock.set_time("2027-02-05T15:00:00Z", 0) + assert w.state()["next_check_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 wakeups as wakeups_mod + w = _wakeups() + for n in range(wakeups_mod._HISTORY + 10): + w.cancel(w.schedule(f"check {n}", at="PT1H")["check_id"]) + checks = w.state()["checks"] + assert len(checks) == wakeups_mod._HISTORY and checks[-1]["note"] == f"check {wakeups_mod._HISTORY + 9}" + w.notify("Messages", "hi") + w.take({}) + assert w._notifications == [] + + def test_clear_turns_checks_off_and_forgets_everything(): w = _wakeups() w.schedule("x", at="PT1H") From 9007d278dc7cfb7a7ce0e1b8a0febf5d3697283f Mon Sep 17 00:00:00 2001 From: Sarah Chitty Date: Thu, 29 Oct 2026 10:36:42 -0700 Subject: [PATCH 05/14] refactor(gateway): rename wakeups to notifications (urn:agentenv:notifications/v1) What the extension holds is notifications: checks a role schedules for itself, and messages triggers send. "Wakeups" named the effect on the agent. Nothing has merged, so the rename stays in v1. - urn:agentenv:notifications/v1 (EXT_NOTIFICATIONS_URI), routes /notifications/config|state|take|clear, env/gateway/notifications.py with class Notifications. The card says it is unrelated to A2A push notifications. - Task step configure_scheduled_checks; it records metadata.scheduled_check_configurations. - Trigger action notify (TRIGGER_NOTIFY); a taken trigger notification has kind "trigger". - state's next_due_at replaces next_check_at and is the earliest of the active checks' next time and the pending notifications' time, so a runner waiting on it no longer sits on a queued notification. The agent's tools keep their names: schedule_check, list_scheduled_checks, cancel_scheduled_check. Co-Authored-By: Claude Opus 5.5 --- src/agent_env/env/gateway/constants.py | 20 +-- src/agent_env/env/gateway/gateway.py | 72 ++++---- .../gateway/{wakeups.py => notifications.py} | 104 ++++++------ src/agent_env/env/gateway/triggers.py | 16 +- src/agent_env/task/episode/rules.py | 2 +- src/agent_env/task_step/registry.py | 4 +- ...keups.py => configure_scheduled_checks.py} | 27 +-- .../task_steps/register_env_triggers.py | 4 +- tst/unit/env/gateway/gateway_card_test.py | 2 +- ...{wakeups_test.py => notifications_test.py} | 155 +++++++++++------- ....py => test_configure_scheduled_checks.py} | 26 +-- .../task_step/test_register_env_triggers.py | 6 +- 12 files changed, 237 insertions(+), 201 deletions(-) rename src/agent_env/env/gateway/{wakeups.py => notifications.py} (76%) rename src/agent_env/task_step/task_steps/{configure_wakeups.py => configure_scheduled_checks.py} (75%) rename tst/unit/env/gateway/{wakeups_test.py => notifications_test.py} (67%) rename tst/unit/task_step/{test_configure_wakeups.py => test_configure_scheduled_checks.py} (72%) diff --git a/src/agent_env/env/gateway/constants.py b/src/agent_env/env/gateway/constants.py index f0e1ba316..6c95f0ac1 100644 --- a/src/agent_env/env/gateway/constants.py +++ b/src/agent_env/env/gateway/constants.py @@ -78,7 +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_WAKEUPS_URI = "urn:agentenv:wakeups/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 @@ -123,10 +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_AGENT = "notify_agent" +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_NOTIFY_AGENT) + 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" @@ -248,16 +248,16 @@ def random_mcp_server_name() -> str: "required": False, }, { - "uri": EXT_WAKEUPS_URI, - "description": "Wakeups (urn:agentenv:wakeups/v1): what is due to wake the agent between its turns, on the virtual clock: checks it scheduled for itself (schedule_check, which config turns on) and notifications triggers sent it (the notify_agent action). state says when the next is due; take hands out each due one once (harness control path).", - "params": {"endpoint": "/wakeups/state", "methods": { - "config": {"method": "POST", "endpoint": "/wakeups/config", "request": {"type": "object", "properties": { + "uri": EXT_NOTIFICATIONS_URI, + "description": "Notifications (urn:agentenv:notifications/v1): what comes due for an agent between its turns, on the virtual clock: checks it scheduled for itself (schedule_check, which config turns on) and notifications triggers sent it (the notify action). state says when the next is due; take hands out each due one once (harness control path). Unrelated to A2A push notifications.", + "params": {"endpoint": "/notifications/state", "methods": { + "config": {"method": "POST", "endpoint": "/notifications/config", "request": {"type": "object", "properties": { "scheduled_checks": {"type": "boolean"}, "max_active": {"type": "integer", "minimum": 1}, "min_interval": {"type": "string"}}}}, - "state": {"method": "GET", "endpoint": "/wakeups/state", "request": {"type": "object", "properties": {}}}, - "take": {"method": "POST", "endpoint": "/wakeups/take", "request": {"type": "object", "properties": { + "state": {"method": "GET", "endpoint": "/notifications/state", "request": {"type": "object", "properties": {}}}, + "take": {"method": "POST", "endpoint": "/notifications/take", "request": {"type": "object", "properties": { "until": {"type": "string"}}}}, - "clear": {"method": "POST", "endpoint": "/wakeups/clear", "request": {"type": "object", "properties": {}}}}}, + "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 de0cf5e5f..f56fb52e7 100644 --- a/src/agent_env/env/gateway/gateway.py +++ b/src/agent_env/env/gateway/gateway.py @@ -73,7 +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 .wakeups import WAKEUP_TOOL_NAMES, WakeupError, Wakeups, build_wakeup_tools +from .notifications import CHECK_TOOL_NAMES, NotificationError, Notifications, build_check_tools logger = logging.getLogger(__name__) @@ -174,7 +174,7 @@ async def list_website_urls() -> list[str]: ) ) self._clock = Clock() - self._wakeups = Wakeups(lambda: self._clock) + self._notifications = Notifications(lambda: self._clock) self._env_get_time_url_cache: str | None = None self._install_role_filters() @@ -316,46 +316,46 @@ 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 _wakeup_write(request: Request, method) -> Response: + 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 WakeupError as e: + except NotificationError as e: return self._json_response({"ok": False, "error": str(e)}, 400) - @self._mcp.custom_route("/wakeups/config", methods=["POST"]) - async def wakeups_config(request: Request) -> Response: + @self._mcp.custom_route("/notifications/config", methods=["POST"]) + async def notifications_config(request: Request) -> Response: try: # the backing servers' tools must be known, or a later discovery would replace ours unseen 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(WAKEUP_TOOL_NAMES) & set(self._tool_server_urls)) + 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) - response = await _wakeup_write(request, self._wakeups.configure) - if self._wakeups.enabled: - self._register_wakeup_tools() + response = await _notifications_write(request, self._notifications.configure) + if self._notifications.enabled: + self._register_check_tools() else: - self._unregister_wakeup_tools() + self._unregister_check_tools() return response - @self._mcp.custom_route("/wakeups/state", methods=["GET"]) - async def wakeups_state(request: Request) -> Response: - return self._json_response(self._wakeups.state(), 200) + @self._mcp.custom_route("/notifications/state", methods=["GET"]) + async def notifications_state(request: Request) -> Response: + return self._json_response(self._notifications.state(), 200) - @self._mcp.custom_route("/wakeups/take", methods=["POST"]) - async def wakeups_take(request: Request) -> Response: - return await _wakeup_write(request, self._wakeups.take) + @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("/wakeups/clear", methods=["POST"]) - async def wakeups_clear(request: Request) -> Response: - self._unregister_wakeup_tools() - return self._json_response(self._wakeups.clear(), 200) + @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: @@ -944,11 +944,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 and the wakeup tools are left out: a backing server's tool of that name wins, and the clock and - # wakeups refuse to turn on 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 and tool.name not in WAKEUP_TOOL_NAMES} + 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) @@ -968,8 +968,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_wakeup_descriptors(): - logger.info("Re-mirrored the wakeup tool descriptors 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() @@ -1195,24 +1195,24 @@ def _unregister_get_time_tool(self) -> None: self._server_tools.pop("gateway", None) self._trigger_engine.invalidate_readonly_cache() - def _register_wakeup_tools(self) -> None: + def _register_check_tools(self) -> None: """Expose schedule_check and its siblings (idempotent). Called when a task turns scheduled checks on.""" tools = self._mcp._tool_manager._tools - for tool in build_wakeup_tools(self._wakeups, self._create_arg_model_from_schema): + for tool in build_check_tools(self._notifications, self._create_arg_model_from_schema): if tool.name not in self._tool_server_urls: tools.setdefault(tool.name, tool) - if self._mirror_wakeup_descriptors(): + if self._mirror_check_descriptors(): self._trigger_engine.invalidate_readonly_cache() - def _mirror_wakeup_descriptors(self) -> bool: - """Mirror the registered wakeup tools into `_server_tools`, as `_mirror_get_time_descriptor` does for + 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._wakeups.enabled: + 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 WAKEUP_TOOL_NAMES: + 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 @@ -1221,13 +1221,13 @@ def _mirror_wakeup_descriptors(self) -> bool: added = True return added - def _unregister_wakeup_tools(self) -> None: + 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 WAKEUP_TOOL_NAMES: + 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 WAKEUP_TOOL_NAMES + 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 diff --git a/src/agent_env/env/gateway/wakeups.py b/src/agent_env/env/gateway/notifications.py similarity index 76% rename from src/agent_env/env/gateway/wakeups.py rename to src/agent_env/env/gateway/notifications.py index 7bc045499..b968edf92 100644 --- a/src/agent_env/env/gateway/wakeups.py +++ b/src/agent_env/env/gateway/notifications.py @@ -1,12 +1,12 @@ -"""What wakes an agent between its turns (urn:agentenv:wakeups/v1). +"""Notifications for an agent, due on the gateway's virtual clock (urn:agentenv:notifications/v1). -Two sources, both on the gateway's virtual clock: +Two kinds: - checks the agent schedules for itself with the schedule_check / list_scheduled_checks / cancel_scheduled_check tools, which exist only once a task turns them on (`configure`); -- notifications a task's triggers send it with the `notify_agent` action, through a channel the task declares. +- notifications a task's triggers send it with the `notify` action, through a channel the task declares. -Nothing here wakes anyone: a harness (a post-turn hook) reads `state` to learn when the next one is due, moves the -clock there, and `take`s what has come due to put it in front of the agent. +Nothing here wakes anyone: a harness reads `state` to learn when the next one is due, may move the clock there, and +`take`s what has come due to put it in front of the agent. Unrelated to A2A push notifications. """ from __future__ import annotations @@ -23,7 +23,7 @@ SCHEDULE_CHECK_TOOL_NAME = "schedule_check" LIST_SCHEDULED_CHECKS_TOOL_NAME = "list_scheduled_checks" CANCEL_SCHEDULED_CHECK_TOOL_NAME = "cancel_scheduled_check" -WAKEUP_TOOL_NAMES = (SCHEDULE_CHECK_TOOL_NAME, LIST_SCHEDULED_CHECKS_TOOL_NAME, CANCEL_SCHEDULED_CHECK_TOOL_NAME) +CHECK_TOOL_NAMES = (SCHEDULE_CHECK_TOOL_NAME, LIST_SCHEDULED_CHECKS_TOOL_NAME, CANCEL_SCHEDULED_CHECK_TOOL_NAME) DEFAULT_MAX_ACTIVE = 10 DEFAULT_MIN_INTERVAL = "PT1H" @@ -59,24 +59,24 @@ } -class WakeupError(ValueError): +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: """An RFC 3339 instant, or an ISO 8601 duration counted from now.""" if not isinstance(text, str) or not text: - raise WakeupError(f"{field} must be an RFC 3339 time or an ISO 8601 duration") + raise NotificationError(f"{field} must be an RFC 3339 time or an ISO 8601 duration") try: return now + _parse_duration(text) if text.startswith("P") else _parse_rfc3339(text) except ClockError as e: - raise WakeupError(f"{field}: {e}") + raise NotificationError(f"{field}: {e}") except OverflowError: - raise WakeupError(f"{field} is too far away") + raise NotificationError(f"{field} is too far away") -class Wakeups: - """The gateway's record of scheduled checks and agent notifications; threadsafe. `clock` returns the gateway's +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]): @@ -86,7 +86,7 @@ def __init__(self, clock: Callable[[], Clock]): self._max_active = DEFAULT_MAX_ACTIVE self._min_interval = _parse_duration(DEFAULT_MIN_INTERVAL) self._checks: dict[str, dict] = {} - self._notifications: list[dict] = [] + self._queue: list[dict] = [] # what triggers sent, pending until taken self._seq = 0 @property @@ -96,19 +96,19 @@ def enabled(self) -> bool: def configure(self, body: Any) -> dict: """Turn scheduled checks on or off, with their limits. Notifications need no switch.""" if not isinstance(body, dict): - raise WakeupError("body must be a JSON object") + raise NotificationError("body must be a JSON object") enabled = body.get("scheduled_checks", True) if not isinstance(enabled, bool): - raise WakeupError("scheduled_checks must be a boolean") + raise NotificationError("scheduled_checks must be a boolean") 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 WakeupError("max_active must be a positive integer") + 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 WakeupError(f"min_interval: {e}") + raise NotificationError(f"min_interval: {e}") if min_interval <= timedelta(0): - raise WakeupError("min_interval must be a positive duration") + raise NotificationError("min_interval must be a positive duration") with self._lock: self._enabled, self._max_active, self._min_interval = enabled, max_active, min_interval if not enabled: # the agent loses its tools, so the checks it can no longer cancel go too @@ -122,7 +122,7 @@ def clear(self) -> dict: with self._lock: self._enabled = False self._checks.clear() - self._notifications.clear() + self._queue.clear() return {"ok": True} @property @@ -132,7 +132,7 @@ def _clock(self) -> Clock: def _now(self) -> datetime: now = self._clock.now() if now is None: - raise WakeupError("this environment's clock is not set, so checks can't be scheduled") + raise NotificationError("this environment's clock is not set, so checks can't be scheduled") return now def _next_id(self, prefix: str) -> str: @@ -143,38 +143,38 @@ def _next_id(self, prefix: str) -> str: def schedule(self, note: Any, at: Any = None, every: Any = None, until: Any = None) -> dict: if not self._enabled: - raise WakeupError("scheduled checks are not available here") + raise NotificationError("scheduled checks are not available here") if not isinstance(note, str) or not note.strip(): - raise WakeupError("note must say what to do when the check comes due") + raise NotificationError("note must say what to do when the check comes due") if len(note) > _MAX_NOTE_CHARS: - raise WakeupError(f"note must be at most {_MAX_NOTE_CHARS} characters") + raise NotificationError(f"note must be at most {_MAX_NOTE_CHARS} characters") now = self._now() step = None if every is not None: try: step = _parse_duration(every) except (ClockError, OverflowError) as e: - raise WakeupError(f"every: {e}") + raise NotificationError(f"every: {e}") if step < self._min_interval: - raise WakeupError(f"every must be at least {_iso_duration(self._min_interval)} apart") + raise NotificationError(f"every must be at least {_iso_duration(self._min_interval)} apart") if at is None and step is None: - raise WakeupError("give at (when) or every (how often)") + 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 WakeupError("every is too long") + raise NotificationError("every is too long") if first <= now: - raise WakeupError(f"at {_iso(first)} has already passed; it is now {_iso(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 WakeupError("until only applies with every") + raise NotificationError("until only applies with every") if end is not None and end < first: - raise WakeupError("until is before the first check") + raise NotificationError("until is before the first check") with self._lock: if sum(c["status"] == "active" for c in self._checks.values()) >= self._max_active: - raise WakeupError(f"you already have {self._max_active} scheduled checks; cancel one first") + 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, "note": note, "next": first, "every": step, "until": end, "created_at": now, "status": "active", "fired": 0} @@ -188,7 +188,7 @@ def cancel(self, check_id: Any) -> dict: with self._lock: check = self._checks.get(check_id) if isinstance(check_id, str) else None if check is None or check["status"] != "active": - raise WakeupError(f"no active scheduled check {check_id!r}") + raise NotificationError(f"no active scheduled check {check_id!r}") check["status"] = "cancelled" self._prune() return {"cancelled": check_id} @@ -205,33 +205,39 @@ def _public(check: dict) -> dict: # ---- trigger action ---- def notify(self, channel: str, text: str, trigger_id: str | None = None) -> dict: - """Queue a notification for the agent at the current virtual time (wall time if the clock is unset).""" + """Queue a notification for the agent at the current virtual time (none while the clock is unset: due at + once).""" now = self._clock.now() with self._lock: - item = {"id": self._next_id("note"), "channel": channel, "text": text, "trigger_id": trigger_id, + item = {"id": self._next_id("notification"), "channel": channel, "text": text, "trigger_id": trigger_id, "at": _iso(now) if now else None, "status": "pending", "_at": now} - self._notifications.append(item) + self._queue.append(item) return _public_note(item) # ---- harness ---- def state(self) -> dict: + """The config, every check (with its status) and the pending notifications. `next_due_at` is the earliest of + the active checks' next time and the pending notifications' time; one queued while the clock was unset is + due now.""" + now = self._clock.now() with self._lock: active = [c for c in self._checks.values() if c["status"] == "active"] - pending = [_public_note(n) for n in self._notifications if n["status"] == "pending"] - nxt = min((c["next"] for c in active), default=None) + queued = [n for n in self._queue if n["status"] == "pending"] + times = [c["next"] for c in active] + [n["_at"] or now for n in queued] + nxt = min((t for t in times if t is not None), default=None) return {"scheduled_checks": self._enabled, "max_active": self._max_active, - "min_interval": _iso_duration(self._min_interval), "next_check_at": _iso(nxt) if nxt else None, + "min_interval": _iso_duration(self._min_interval), "next_due_at": _iso(nxt) if nxt else None, "checks": [{**self._public(c), "status": c["status"], "fired": c["fired"]} for c in self._checks.values()], - "pending_notifications": pending} + "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._notifications = [n for n in self._notifications if n["status"] == "pending"] + self._queue = [n for n in self._queue if n["status"] == "pending"] def take(self, body: Any = None) -> dict: """Everything due by `until` (default: now), oldest first; each is handed out once. A repeating check moves to @@ -244,7 +250,7 @@ def take(self, body: Any = None) -> dict: # 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 until is not None and c["next"] <= until] - notes = [n for n in self._notifications + notes = [n for n in self._queue if n["status"] == "pending" and (until is None or n["_at"] is None or n["_at"] <= until)] for check, times, nxt in checks: due.append((check["next"], {"kind": "scheduled_check", "check_id": check["check_id"], @@ -256,7 +262,7 @@ def take(self, body: Any = None) -> dict: check["next"] = nxt for note in notes: note["status"] = "delivered" - due.append((note["_at"], {"kind": "notification", "id": note["id"], "channel": note["channel"], + due.append((note["_at"], {"kind": "trigger", "id": note["id"], "channel": note["channel"], "text": note["text"], "at": note["at"], "trigger_id": note["trigger_id"]})) self._prune() due.sort(key=lambda d: (d[0] is not None, d[0])) @@ -296,23 +302,23 @@ def _tool_error(text: str) -> CallToolResult: return CallToolResult(isError=True, content=[TextContent(type="text", text=text)]) -def build_wakeup_tools(wakeups: Wakeups, make_arg_model: Callable[[str, dict], Any]) -> list[FastMCPTool]: +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.""" async def schedule_check(note: str, at: str | None = None, every: str | None = None, until: str | None = None) -> dict | CallToolResult: try: - return wakeups.schedule(note, at=at, every=every, until=until) - except WakeupError as e: + return notifications.schedule(note, at=at, every=every, until=until) + except NotificationError as e: return _tool_error(str(e)) async def list_scheduled_checks() -> dict: - return wakeups.list() + return notifications.list() async def cancel_scheduled_check(check_id: str) -> dict | CallToolResult: try: - return wakeups.cancel(check_id) - except WakeupError as e: + return notifications.cancel(check_id) + except NotificationError as e: return _tool_error(str(e)) specs = [ @@ -329,7 +335,7 @@ async def cancel_scheduled_check(check_id: str) -> dict | CallToolResult: def validate_channel(channel: Any, where: str) -> None: - """A notify_agent channel: the short source label the agent sees, e.g. 'Gmail' or 'Curbfinch alert'.""" + """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: diff --git a/src/agent_env/env/gateway/triggers.py b/src/agent_env/env/gateway/triggers.py index 4fba27f50..1bef4c766 100644 --- a/src/agent_env/env/gateway/triggers.py +++ b/src/agent_env/env/gateway/triggers.py @@ -23,7 +23,7 @@ from mcp.types import CallToolResult, TextContent from .clock import ClockError, _advance, _iso, _parse_duration, _parse_rfc3339, _zone -from .wakeups import validate_channel +from .notifications import validate_channel from .caller import Caller from .constants import DEFAULT_ROLE, TRIGGER_IN_FLIGHT_STATUSES, TRIGGER_STATUSES @@ -35,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", "notify_agent") +_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 @@ -743,8 +743,8 @@ 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_agent": - raise TriggerError(f"{w}: a notify_agent action has nothing to verify") + if "verify" in action and atype == "notify": + raise TriggerError(f"{w}: a notify action has nothing to verify") if action.get("delay") is None: action.pop("delay", None) else: @@ -764,7 +764,7 @@ 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_agent": + elif atype == "notify": try: validate_channel(action.get("channel"), w) except ValueError as e: @@ -1373,15 +1373,15 @@ 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_agent": + elif action["type"] == "notify": try: text = _template(action["text"], {}, ctx) except TemplateError as e: self._emit("action_failed", tid, action_index=index, detail=str(e)[:300]) return False - note = self._gw._wakeups.notify(action["channel"], str(text), trigger_id=tid) + note = self._gw._notifications.notify(action["channel"], str(text), trigger_id=tid) # the text may carry what a person wrote, so the log keeps its size, not its words - detail = {"notify_agent": action["channel"], "notification_id": note["id"], "text_len": len(str(text))} + detail = {"notify": action["channel"], "notification_id": note["id"], "text_len": len(str(text))} else: if not await self._run_nl(tid, trig, index, action, ctx): return False diff --git a/src/agent_env/task/episode/rules.py b/src/agent_env/task/episode/rules.py index 2cdf46252..33ad6fa38 100644 --- a/src/agent_env/task/episode/rules.py +++ b/src/agent_env/task/episode/rules.py @@ -147,7 +147,7 @@ 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", "configure_wakeups", + "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", diff --git a/src/agent_env/task_step/registry.py b/src/agent_env/task_step/registry.py index f7fc51f63..3c74f4fcd 100644 --- a/src/agent_env/task_step/registry.py +++ b/src/agent_env/task_step/registry.py @@ -46,7 +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_wakeups import ConfigureWakeupsTaskStep + 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 @@ -101,7 +101,7 @@ def _builtin_registry() -> dict[str, type["TaskStep"]]: _get_type(RegisterEnvTriggersStep): RegisterEnvTriggersStep, _get_type(RegisterAgentTriggersStep): RegisterAgentTriggersStep, _get_type(SyncEnvClockTaskStep): SyncEnvClockTaskStep, - _get_type(ConfigureWakeupsTaskStep): ConfigureWakeupsTaskStep, + _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_wakeups.py b/src/agent_env/task_step/task_steps/configure_scheduled_checks.py similarity index 75% rename from src/agent_env/task_step/task_steps/configure_wakeups.py rename to src/agent_env/task_step/task_steps/configure_scheduled_checks.py index b66a2506f..7e058896f 100644 --- a/src/agent_env/task_step/task_steps/configure_wakeups.py +++ b/src/agent_env/task_step/task_steps/configure_scheduled_checks.py @@ -1,8 +1,9 @@ -"""Task step that turns on the checks an agent can schedule for itself on a deployed env's gateway (wakeups/v1). +"""Task step that turns on the checks an agent can schedule for itself on a deployed env's gateway +(notifications/v1). With it the agent gets schedule_check, list_scheduled_checks and cancel_scheduled_check, on the env's virtual clock. Place it after sync_env_clock (a check needs the clock) and before prompt_agent (the agent needs the tools on its -first turn). A post-turn hook reads the gateway's wakeups state to wake the agent when a check comes due. +first turn). A harness takes what has come due from the gateway's notifications/v1 and puts it in front of the agent. """ from __future__ import annotations @@ -12,8 +13,8 @@ import httpx -from agent_env.env.gateway.constants import EXT_WAKEUPS_URI -from agent_env.env.gateway.wakeups import DEFAULT_MAX_ACTIVE, DEFAULT_MIN_INTERVAL +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 @@ -21,8 +22,8 @@ logger = logging.getLogger(__name__) -class ConfigureWakeupsTaskStep(TaskStep): - type: ClassVar[str] = "configure_wakeups" +class ConfigureScheduledChecksTaskStep(TaskStep): + type: ClassVar[str] = "configure_scheduled_checks" entity_refs = (EntityRef.env("env_id"),) def __init__( @@ -59,7 +60,7 @@ def to_dict(self) -> dict: return base @classmethod - def from_dict(cls, data: dict) -> ConfigureWakeupsTaskStep: + def from_dict(cls, data: dict) -> ConfigureScheduledChecksTaskStep: return cls( **cls._base_from_dict(data), env_id=data["env_id"], @@ -73,17 +74,17 @@ 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_WAKEUPS_URI, "config"): - raise RuntimeError(f"Env '{self.env_id}' runs a gateway without {EXT_WAKEUPS_URI}; redeploy it on a gateway " + 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 = {"scheduled_checks": self.scheduled_checks, "max_active": self.max_active, "min_interval": self.min_interval} try: - state = await deployed.invoke(EXT_WAKEUPS_URI, "config", body, timeout=self.timeout_seconds) + state = await deployed.invoke(EXT_NOTIFICATIONS_URI, "config", body, timeout=self.timeout_seconds) except httpx.HTTPStatusError as e: - raise RuntimeError(f"wakeups config failed (HTTP {e.response.status_code}): {e.response.text}") from e - logger.info(f"configure_wakeups completed for env={self.env_id}") - context.metadata.setdefault("wakeup_configurations", []).append({ + 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}") + 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 ("scheduled_checks", "max_active", "min_interval")}, }) 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 b36970ded..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,7 +17,7 @@ TRIGGER_EXECUTOR_HOURS, TRIGGER_EXECUTOR_HOURS_TIME_ZONE, TRIGGER_NAMED_EXECUTORS, - TRIGGER_NOTIFY_AGENT, + TRIGGER_NOTIFY, TRIGGER_PROVOKING_ROLES, TRIGGER_UNLESS, TRIGGER_WEBSITE, @@ -153,7 +153,7 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: 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_NOTIFY_AGENT: any(a.get("type") == "notify_agent" for t in self.triggers + 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: diff --git a/tst/unit/env/gateway/gateway_card_test.py b/tst/unit/env/gateway/gateway_card_test.py index 704cc02c2..6b71a6b7c 100644 --- a/tst/unit/env/gateway/gateway_card_test.py +++ b/tst/unit/env/gateway/gateway_card_test.py @@ -206,7 +206,7 @@ def test_gateway_card_lists_its_nine_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:wakeups/v1", + "urn:agentenv:changes/v1", "urn:agentenv:notifications/v1", ] diff --git a/tst/unit/env/gateway/wakeups_test.py b/tst/unit/env/gateway/notifications_test.py similarity index 67% rename from tst/unit/env/gateway/wakeups_test.py rename to tst/unit/env/gateway/notifications_test.py index be0a13db7..7c9e3ec6a 100644 --- a/tst/unit/env/gateway/wakeups_test.py +++ b/tst/unit/env/gateway/notifications_test.py @@ -1,4 +1,4 @@ -"""Unit tests for what wakes an agent between turns (urn:agentenv:wakeups/v1): scheduled checks and notify_agent.""" +"""Unit tests for an agent's notifications (urn:agentenv:notifications/v1): scheduled checks and the notify action.""" from __future__ import annotations import asyncio @@ -11,9 +11,10 @@ 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 from agent_env.env.gateway.gateway import Gateway from agent_env.env.gateway.triggers import TriggerEngine, TriggerError -from agent_env.env.gateway.wakeups import WAKEUP_TOOL_NAMES, WakeupError, Wakeups +from agent_env.env.gateway.notifications import CHECK_TOOL_NAMES, NotificationError, Notifications T0 = "2027-02-01T15:00:00Z" # Mon 7:00 AM Pacific @@ -35,24 +36,24 @@ def _frozen_clock(at: str = T0) -> Clock: return clock -def _wakeups(clock: Clock | None = None, **config) -> Wakeups: +def _notifications(clock: Clock | None = None, **config) -> Notifications: clock = clock or _frozen_clock() - w = Wakeups(lambda: clock) + w = Notifications(lambda: clock) w.configure({"scheduled_checks": True, **config}) return w -# ---- Wakeups ---- +# ---- Notifications ---- def test_scheduled_checks_are_off_until_a_task_turns_them_on(): - w = Wakeups(lambda: _frozen_clock()) + w = Notifications(lambda: _frozen_clock()) assert w.state()["scheduled_checks"] is False - with pytest.raises(WakeupError, match="not available"): + 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 = _wakeups() + 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"] @@ -70,51 +71,71 @@ def test_a_check_takes_a_duration_from_now_or_a_time_with_its_offset(): ({"note": "x", "at": "P3000000D"}, "too far away"), ]) def test_bad_checks_are_refused_with_the_reason(kwargs, error): - with pytest.raises(WakeupError, match=error): - _wakeups().schedule(**kwargs) + with pytest.raises(NotificationError, match=error): + _notifications().schedule(**kwargs) @pytest.mark.parametrize("text", ["PT3H", "P1D", "P1DT6H30M", "PT0.001S", "PT1M0.5S"]) def test_durations_are_echoed_in_the_form_the_tools_take(text): - w = _wakeups(min_interval="PT0.001S") + w = _notifications(min_interval="PT0.001S") assert w.schedule("x", every=text)["every"] == text - assert _wakeups(min_interval=text).state()["min_interval"] == 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(WakeupError, match=r"at least PT3H apart"): - _wakeups(min_interval="PT3H").schedule("x", every="PT1H") + 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 = _wakeups(max_active=2) + w = _notifications(max_active=2) first = w.schedule("a", at="PT1H")["check_id"] w.schedule("b", at="PT2H") - with pytest.raises(WakeupError, match="already have 2"): + with pytest.raises(NotificationError, match="already have 2"): w.schedule("c", at="PT3H") w.cancel(first) w.schedule("c", at="PT3H") - with pytest.raises(WakeupError, match="no active scheduled check"): + 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 = _wakeups(clock) + w = _notifications(clock) w.schedule("look at 9", at="PT2H") w.schedule("look at noon", at="PT5H") - assert w.state()["next_check_at"] == "2027-02-01T17:00:00Z" + 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["kind"], i["note"], 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_check_at"] == "2027-02-01T20:00:00Z" + 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 = _wakeups(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"] @@ -124,7 +145,7 @@ def test_a_repeating_check_moves_past_a_long_jump_and_says_how_often_it_came_due def test_a_jump_over_a_short_every_is_counted_not_stepped(): clock = _frozen_clock() - w = _wakeups(clock, min_interval="PT0.001S") + 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"] @@ -134,15 +155,15 @@ def test_a_jump_over_a_short_every_is_counted_not_stepped(): def test_a_repeating_check_ends_at_until(): clock = _frozen_clock() - w = _wakeups(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_check_at"] is None + assert w.list()["checks"] == [] and w.state()["next_due_at"] is None def test_take_until_looks_ahead_without_moving_the_clock(): - w = _wakeups() + w = _notifications() w.schedule("later", at="PT3H") assert w.take({"until": "PT1H"})["items"] == [] assert [i["note"] for i in w.take({"until": "PT3H"})["items"]] == ["later"] @@ -150,18 +171,18 @@ def test_take_until_looks_ahead_without_moving_the_clock(): def test_notifications_queue_at_the_virtual_time_and_are_taken_once(): clock = _frozen_clock() - w = Wakeups(lambda: clock) # notifications need no switch + 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["kind"], item["text"]) == ("notification", "Jamie: did the rug thing get sorted?") + assert (item["kind"], item["text"]) == ("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 = _wakeups(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"] @@ -170,28 +191,28 @@ def test_due_is_judged_on_the_time_not_its_text(): def test_turning_checks_off_cancels_the_ones_still_to_come(): clock = _frozen_clock() - w = _wakeups(clock) + w = _notifications(clock) w.schedule("sweep", every="P1D") w.configure({"scheduled_checks": False}) clock.set_time("2027-02-05T15:00:00Z", 0) - assert w.state()["next_check_at"] is None and w.take({})["items"] == [] + 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 wakeups as wakeups_mod - w = _wakeups() - for n in range(wakeups_mod._HISTORY + 10): + 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) == wakeups_mod._HISTORY and checks[-1]["note"] == f"check {wakeups_mod._HISTORY + 9}" + assert len(checks) == notifications_mod._HISTORY and checks[-1]["note"] == f"check {notifications_mod._HISTORY + 9}" w.notify("Messages", "hi") w.take({}) - assert w._notifications == [] + assert w._queue == [] def test_clear_turns_checks_off_and_forgets_everything(): - w = _wakeups() + w = _notifications() w.schedule("x", at="PT1H") w.notify("Gmail", "hello") w.clear() @@ -205,8 +226,8 @@ def test_clear_turns_checks_off_and_forgets_everything(): ({"min_interval": "1h"}, "min_interval"), ]) def test_bad_config_is_refused(body, error): - with pytest.raises(WakeupError, match=error): - Wakeups(lambda: _frozen_clock()).configure(body) + with pytest.raises(NotificationError, match=error): + Notifications(lambda: _frozen_clock()).configure(body) # ---- the gateway ---- @@ -235,25 +256,33 @@ 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(WAKEUP_TOOL_NAMES) & set(tools) - r = _request(gateway, "POST", "/wakeups/config", {"max_active": 5}) + assert not set(CHECK_TOOL_NAMES) & set(tools) + r = _request(gateway, "POST", "/notifications/config", {"max_active": 5}) assert r.status_code == 200 and r.json()["max_active"] == 5 - assert set(WAKEUP_TOOL_NAMES) <= set(tools) and set(WAKEUP_TOOL_NAMES) <= _gateway_tool_names(gateway) - assert _request(gateway, "POST", "/wakeups/clear", {}).status_code == 200 - assert not set(WAKEUP_TOOL_NAMES) & set(tools) and not set(WAKEUP_TOOL_NAMES) & _gateway_tool_names(gateway) + 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) def test_turning_checks_off_removes_the_tools(gateway): - _request(gateway, "POST", "/wakeups/config", {}) - _request(gateway, "POST", "/wakeups/config", {"scheduled_checks": False}) - assert not set(WAKEUP_TOOL_NAMES) & set(gateway._mcp._tool_manager._tools) + _request(gateway, "POST", "/notifications/config", {}) + _request(gateway, "POST", "/notifications/config", {"scheduled_checks": False}) + 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", "/wakeups/config", {}) + r = _request(gateway, "POST", "/notifications/config", {}) assert r.status_code == 409 and "schedule_check" in r.json()["error"] @@ -265,7 +294,7 @@ async def discover(): gateway._tools_discovered = True monkeypatch.setattr(gateway, "_ensure_tools_discovered", discover) - r = _request(gateway, "POST", "/wakeups/config", {}) + r = _request(gateway, "POST", "/notifications/config", {}) assert r.status_code == 409 and "schedule_check" in r.json()["error"] @@ -274,37 +303,37 @@ async def discover(): raise RuntimeError("backing server down") monkeypatch.setattr(gateway, "_ensure_tools_discovered", discover) - r = _request(gateway, "POST", "/wakeups/config", {}) + r = _request(gateway, "POST", "/notifications/config", {}) assert r.status_code == 503 and "retry" in r.json()["error"] - assert not set(WAKEUP_TOOL_NAMES) & set(gateway._mcp._tool_manager._tools) + assert not set(CHECK_TOOL_NAMES) & set(gateway._mcp._tool_manager._tools) def test_a_bad_config_is_a_400(gateway): - assert _request(gateway, "POST", "/wakeups/config", {"max_active": -1}).status_code == 400 + 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", "/wakeups/config", {}) + _request(gateway, "POST", "/notifications/config", {}) tool = gateway._mcp._tool_manager._tools["schedule_check"] made = asyncio.run(tool.fn(note="check rugs", at="PT2H")) assert made["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", "/wakeups/state").json() - assert state["next_check_at"] == "2027-02-01T17:00:00Z" + 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", "/wakeups/take", {}).json()["items"] + items = _request(gateway, "POST", "/notifications/take", {}).json()["items"] assert [i["note"] for i in items] == ["check rugs"] def test_list_is_read_only_so_it_does_not_re_check_state_triggers(gateway): - _request(gateway, "POST", "/wakeups/config", {}) + _request(gateway, "POST", "/notifications/config", {}) 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 -# ---- notify_agent ---- +# ---- notify ---- class _FakeGateway: def __init__(self) -> None: @@ -313,7 +342,7 @@ def __init__(self) -> None: self._server_tools: dict = {} self._tool_server_urls: dict = {} self._clock = _frozen_clock() - self._wakeups = Wakeups(lambda: self._clock) + self._notifications = Notifications(lambda: self._clock) self.TOOL_CALL_TIMEOUT_S = 30 def _query_changelog_id(self): @@ -333,21 +362,21 @@ def _ok(text: str = "{}") -> CallToolResult: def _notify_trigger(**action) -> dict: return {"id": "jamie-texts", "when": {"type": "action", "tool": "messaging_send_message"}, - "actions": [{"type": "notify_agent", "channel": "Messages", "text": "Jamie: ok", **action}]} + "actions": [{"type": "notify", "channel": "Messages", "text": "Jamie: ok", **action}]} @pytest.mark.asyncio -async def test_notify_agent_queues_a_templated_notification(): +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._wakeups.state()["pending_notifications"] + [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_agent"] == "Messages" and "got your text" not in json.dumps(ok[0]) + assert ok and ok[0]["detail"]["notify"] == "Messages" and "got your text" not in json.dumps(ok[0]) @pytest.mark.parametrize("action, error", [ @@ -356,7 +385,7 @@ async def test_notify_agent_queues_a_templated_notification(): ({"verify": {"tool": "x", "predicate": {"exists": True}}}, "nothing to verify"), ({"text": "${result.id}", "channel": "Gmail"}, None), ]) -def test_notify_agent_is_validated_at_registration(action, error): +def test_notify_is_validated_at_registration(action, error): engine = TriggerEngine(_FakeGateway()) body = {"watch_roles": ["default"], "triggers": [_notify_trigger(**action)]} if error is None: diff --git a/tst/unit/task_step/test_configure_wakeups.py b/tst/unit/task_step/test_configure_scheduled_checks.py similarity index 72% rename from tst/unit/task_step/test_configure_wakeups.py rename to tst/unit/task_step/test_configure_scheduled_checks.py index 1ab1941a4..b7621b1fc 100644 --- a/tst/unit/task_step/test_configure_wakeups.py +++ b/tst/unit/task_step/test_configure_scheduled_checks.py @@ -1,5 +1,5 @@ -"""ConfigureWakeupsTaskStep posts the env's wakeups/v1 config as the stored card declares it and records it, and -refuses before any request on a gateway without wakeups/v1. Only the gateway HTTP boundary is mocked.""" +"""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 @@ -9,10 +9,10 @@ import pytest from agent_env.env.env import DeployedEnv, DeployedGatewayEnv -from agent_env.env.gateway.constants import EXT_WAKEUPS_URI, GATEWAY_EXTENSIONS, WELL_KNOWN_PATH +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_wakeups import ConfigureWakeupsTaskStep +from agent_env.task_step.task_steps.configure_scheduled_checks import ConfigureScheduledChecksTaskStep _GATEWAY = "http://gw:18765" @@ -22,17 +22,17 @@ async def test_posts_the_config_and_records_what_the_gateway_applied(monkeypatch sent = _mock_gateway(monkeypatch, {"scheduled_checks": True, "max_active": 5, "min_interval": "PT1H"}) out = await _step(max_active=5).execute(TaskStepContext(deployed_envs=[_record()], metadata={})) [request] = sent - assert (request.method, str(request.url)) == ("POST", f"{_GATEWAY}/wakeups/config") + assert (request.method, str(request.url)) == ("POST", f"{_GATEWAY}/notifications/config") assert json.loads(request.content) == {"scheduled_checks": True, "max_active": 5, "min_interval": "PT1H"} - [entry] = out.metadata["wakeup_configurations"] + [entry] = out.metadata["scheduled_check_configurations"] assert entry["applied"] == {"scheduled_checks": True, "max_active": 5, "min_interval": "PT1H"} @pytest.mark.asyncio -async def test_a_gateway_without_wakeups_refuses_before_any_request(monkeypatch): +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_WAKEUPS_URI] - with pytest.raises(RuntimeError, match="without urn:agentenv:wakeups/v1"): + 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 == [] @@ -47,9 +47,9 @@ async def test_a_refused_config_raises_with_the_gateway_s_reason(monkeypatch): def test_round_trips_and_is_registered(): step = _step(max_active=3, min_interval="PT2H") - again = ConfigureWakeupsTaskStep.from_dict(step.to_dict()) + again = ConfigureScheduledChecksTaskStep.from_dict(step.to_dict()) assert (again.max_active, again.min_interval, again.scheduled_checks) == (3, "PT2H", True) - assert get_task_step_registry()["configure_wakeups"] is ConfigureWakeupsTaskStep + assert get_task_step_registry()["configure_scheduled_checks"] is ConfigureScheduledChecksTaskStep @pytest.mark.parametrize("kwargs", [{"max_active": 0}, {"scheduled_checks": "yes"}]) @@ -58,8 +58,8 @@ def test_bad_settings_are_refused_when_the_step_is_built(kwargs): _step(**kwargs) -def _step(**kwargs) -> ConfigureWakeupsTaskStep: - return ConfigureWakeupsTaskStep(id="wakeups-1", version=None, env_id="env-x", **kwargs) +def _step(**kwargs) -> ConfigureScheduledChecksTaskStep: + return ConfigureScheduledChecksTaskStep(id="checks-1", version=None, env_id="env-x", **kwargs) def _record(extensions: list = GATEWAY_EXTENSIONS) -> DeployedEnv: diff --git a/tst/unit/task_step/test_register_env_triggers.py b/tst/unit/task_step/test_register_env_triggers.py index 7c7c533b7..1abba6959 100644 --- a/tst/unit/task_step/test_register_env_triggers.py +++ b/tst/unit/task_step/test_register_env_triggers.py @@ -581,13 +581,13 @@ def handle(request: httpx.Request) -> httpx.Response: @pytest.mark.asyncio -async def test_a_gateway_without_notify_agent_refuses_it(monkeypatch): +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_agent", "channel": "Gmail", "text": "New email"}]}] + "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_agent'\]"): + with pytest.raises(RuntimeError, match=r"does not declare \['notify'\]"): await _step(triggers=notify).execute(context) assert sent == [] context.deployed_envs = [_record()] From 5f847fafc4db104416041e5d840fc087b76dc56a Mon Sep 17 00:00:00 2001 From: Sarah Chitty Date: Thu, 29 Oct 2026 10:37:13 -0700 Subject: [PATCH 06/14] fix(gateway): schedule_check returns what the gateway decided, not the note back The result echoed the note the agent had just written: up to 2,000 characters in its context with nothing new, and the trajectory already logs the call's arguments. It now returns check_id, next and, when set, every and until. list_scheduled_checks still shows each note, since that is how the agent tells its checks apart; state and take keep the full note for the harness. Co-Authored-By: Claude Opus 5.5 --- src/agent_env/env/gateway/notifications.py | 3 ++- tst/unit/env/gateway/notifications_test.py | 12 +++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/agent_env/env/gateway/notifications.py b/src/agent_env/env/gateway/notifications.py index b968edf92..7a3a8905d 100644 --- a/src/agent_env/env/gateway/notifications.py +++ b/src/agent_env/env/gateway/notifications.py @@ -178,7 +178,8 @@ def schedule(self, note: Any, at: Any = None, every: Any = None, until: Any = No cid = self._next_id("check") self._checks[cid] = {"check_id": cid, "note": note, "next": first, "every": step, "until": end, "created_at": now, "status": "active", "fired": 0} - return self._public(self._checks[cid]) + # 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) -> dict: with self._lock: diff --git a/tst/unit/env/gateway/notifications_test.py b/tst/unit/env/gateway/notifications_test.py index 7c9e3ec6a..fc19bcc69 100644 --- a/tst/unit/env/gateway/notifications_test.py +++ b/tst/unit/env/gateway/notifications_test.py @@ -59,6 +59,16 @@ def test_a_check_takes_a_duration_from_now_or_a_time_with_its_offset(): 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"), @@ -316,7 +326,7 @@ def test_the_agent_schedules_through_the_tool_and_the_harness_takes_it(gateway): _request(gateway, "POST", "/notifications/config", {}) tool = gateway._mcp._tool_manager._tools["schedule_check"] made = asyncio.run(tool.fn(note="check rugs", at="PT2H")) - assert made["next"] == "2027-02-01T17:00:00Z" + 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() From 01f81d48c7a8fc376b987eb95c415d956cded893 Mon Sep 17 00:00:00 2001 From: Sarah Chitty Date: Thu, 29 Oct 2026 10:41:46 -0700 Subject: [PATCH 07/14] feat(gateway): notifications have a recipient; checks are off unless a task names roles Any role that listed the gateway's tools saw schedule_check, persona executors included, and one shared queue meant a check a persona scheduled reached the solver, since take returned everything to whoever called it. - config takes roles, the roles that get the scheduled-check tools. It defaults to [] and replaces the scheduled_checks boolean: [] turns checks off, removing the tools and cancelling active checks; a role left out loses its own. Unknown config fields are refused, so a leftover scheduled_checks is caught. - The gateway's role filter (_is_disabled) hides the three tools from every role the config doesn't name, on MCP tools/list and tools/call and on /step, whatever the tool-access rules say. - schedule_check records the calling role as the check's recipient, to; list_scheduled_checks and cancel_scheduled_check see only that role's checks, and max_active counts each role's checks on their own. /step binds its caller's role for gateway tools, as the MCP path already does. - The notify trigger action takes an optional to (default "default", the solver's role). Receiving needs no tools, so it need not be one of the config's roles. - take takes to (default "default") and hands out only that recipient's items; state takes ?to= for one recipient's items and next_due_at, and shows every recipient's without it. - configure_scheduled_checks requires a non-empty roles, so a task always names who gets the tools. Co-Authored-By: Claude Opus 5.5 --- src/agent_env/env/gateway/constants.py | 9 +- src/agent_env/env/gateway/gateway.py | 17 +- src/agent_env/env/gateway/notifications.py | 150 ++++++++++------ src/agent_env/env/gateway/triggers.py | 12 +- .../task_steps/configure_scheduled_checks.py | 36 ++-- tst/unit/env/gateway/notifications_test.py | 163 ++++++++++++++++-- .../test_configure_scheduled_checks.py | 25 ++- 7 files changed, 313 insertions(+), 99 deletions(-) diff --git a/src/agent_env/env/gateway/constants.py b/src/agent_env/env/gateway/constants.py index 6c95f0ac1..8b9bf81dd 100644 --- a/src/agent_env/env/gateway/constants.py +++ b/src/agent_env/env/gateway/constants.py @@ -249,14 +249,15 @@ def random_mcp_server_name() -> str: }, { "uri": EXT_NOTIFICATIONS_URI, - "description": "Notifications (urn:agentenv:notifications/v1): what comes due for an agent between its turns, on the virtual clock: checks it scheduled for itself (schedule_check, which config turns on) and notifications triggers sent it (the notify action). state says when the next is due; take hands out each due one once (harness control path). Unrelated to A2A push notifications.", + "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). Unrelated to A2A push notifications.", "params": {"endpoint": "/notifications/state", "methods": { "config": {"method": "POST", "endpoint": "/notifications/config", "request": {"type": "object", "properties": { - "scheduled_checks": {"type": "boolean"}, "max_active": {"type": "integer", "minimum": 1}, + "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": {}}}, + "state": {"method": "GET", "endpoint": "/notifications/state", "request": {"type": "object", "properties": { + "to": {"type": "string"}}}}, "take": {"method": "POST", "endpoint": "/notifications/take", "request": {"type": "object", "properties": { - "until": {"type": "string"}}}}, + "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 f56fb52e7..08d5ccf35 100644 --- a/src/agent_env/env/gateway/gateway.py +++ b/src/agent_env/env/gateway/gateway.py @@ -346,7 +346,10 @@ async def notifications_config(request: Request) -> Response: @self._mcp.custom_route("/notifications/state", methods=["GET"]) async def notifications_state(request: Request) -> Response: - return self._json_response(self._notifications.state(), 200) + 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: @@ -468,7 +471,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, {}) @@ -1130,7 +1136,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) diff --git a/src/agent_env/env/gateway/notifications.py b/src/agent_env/env/gateway/notifications.py index 7a3a8905d..b4aa2485f 100644 --- a/src/agent_env/env/gateway/notifications.py +++ b/src/agent_env/env/gateway/notifications.py @@ -1,9 +1,10 @@ """Notifications for an agent, due on the gateway's virtual clock (urn:agentenv:notifications/v1). -Two kinds: -- checks the agent schedules for itself with the schedule_check / list_scheduled_checks / cancel_scheduled_check - tools, which exist only once a task turns them on (`configure`); -- notifications a task's triggers send it with the `notify` action, through a channel the task declares. +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: a harness reads `state` to learn when the next one is due, may move the clock there, and `take`s what has come due to put it in front of the agent. Unrelated to A2A push notifications. @@ -18,7 +19,9 @@ from mcp.server.fastmcp.utilities.func_metadata import FuncMetadata from mcp.types import CallToolResult, TextContent, ToolAnnotations +from .caller import Caller from .clock import 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" @@ -29,6 +32,8 @@ DEFAULT_MIN_INTERVAL = "PT1H" _MAX_NOTE_CHARS = 2000 _MAX_CHANNEL_CHARS = 60 +_MAX_ROLE_CHARS = 100 +_CONFIG_FIELDS = ("roles", "max_active", "min_interval") _HISTORY = 50 # finished and cancelled checks state() still shows SCHEDULE_CHECK_DESCRIPTION = ( @@ -82,24 +87,31 @@ class Notifications: def __init__(self, clock: Callable[[], Clock]): self._clock_of = clock self._lock = threading.Lock() - self._enabled = False + 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._seq = 0 + @property + def roles(self) -> tuple[str, ...]: + return self._roles + @property def enabled(self) -> bool: - return self._enabled + """Whether any role has the scheduled-check tools.""" + return bool(self._roles) def configure(self, body: Any) -> dict: - """Turn scheduled checks on or off, with their limits. Notifications need no switch.""" + """Name the roles that get the scheduled-check tools, with their limits; `roles: []` (the default) turns them + off. Notifications need no switch.""" if not isinstance(body, dict): raise NotificationError("body must be a JSON object") - enabled = body.get("scheduled_checks", True) - if not isinstance(enabled, bool): - raise NotificationError("scheduled_checks must be a boolean") + 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") @@ -110,17 +122,17 @@ def configure(self, body: Any) -> dict: if min_interval <= timedelta(0): raise NotificationError("min_interval must be a positive duration") with self._lock: - self._enabled, self._max_active, self._min_interval = enabled, max_active, min_interval - if not enabled: # the agent loses its tools, so the checks it can no longer cancel go too - for check in self._checks.values(): - if check["status"] == "active": - check["status"] = "cancelled" - self._prune() + 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: with self._lock: - self._enabled = False + self._roles = () self._checks.clear() self._queue.clear() return {"ok": True} @@ -141,8 +153,10 @@ def _next_id(self, prefix: str) -> str: # ---- agent tools ---- - def schedule(self, note: Any, at: Any = None, every: Any = None, until: Any = None) -> dict: - if not self._enabled: + 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") @@ -173,22 +187,25 @@ def schedule(self, note: Any, at: Any = None, every: Any = None, until: Any = No if end is not None and end < first: raise NotificationError("until is before the first check") with self._lock: - if sum(c["status"] == "active" for c in self._checks.values()) >= self._max_active: + 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, "note": note, "next": first, "every": step, "until": end, + 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) -> dict: + 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"]} + 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) -> dict: + 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": + 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() @@ -205,32 +222,33 @@ def _public(check: dict) -> dict: # ---- trigger action ---- - def notify(self, channel: str, text: str, trigger_id: str | None = None) -> dict: - """Queue a notification for the agent at the current virtual time (none while the clock is unset: due at - once).""" + 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"), "channel": channel, "text": text, "trigger_id": trigger_id, - "at": _iso(now) if now else None, "status": "pending", "_at": now} + 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) -> dict: - """The config, every check (with its status) and the pending notifications. `next_due_at` is the earliest of - the active checks' next time and the pending notifications' time; one queued while the clock was unset is - due now.""" + 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: - active = [c for c in self._checks.values() if c["status"] == "active"] - queued = [n for n in self._queue if n["status"] == "pending"] - times = [c["next"] for c in active] + [n["_at"] or now for n in queued] + 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 {"scheduled_checks": self._enabled, "max_active": self._max_active, + 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": [{**self._public(c), "status": c["status"], "fired": c["fired"]} - for c in self._checks.values()], + "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: @@ -241,20 +259,22 @@ def _prune(self) -> None: self._queue = [n for n in self._queue if n["status"] == "pending"] def take(self, body: Any = None) -> dict: - """Everything due by `until` (default: now), oldest first; each is handed out once. A repeating check moves to - its next time after `until` and says how many times it came due, so a long jump wakes the agent once.""" + """Everything due for the recipient `to` (default: the solver's role) by `until` (default: now), oldest + first; each is handed out once. A repeating check moves to its next time after `until` and says how many + times it came due, so a long jump wakes the agent once.""" body = body if isinstance(body, dict) else {} + to = _recipient(body.get("to", DEFAULT_ROLE)) 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 until is not None and c["next"] <= until] - notes = [n for n in self._queue - if n["status"] == "pending" and (until is None or n["_at"] is None or n["_at"] <= until)] + 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"], {"kind": "scheduled_check", "check_id": check["check_id"], + due.append((check["next"], {"kind": "scheduled_check", "check_id": check["check_id"], "to": to, "note": check["note"], "at": _iso(check["next"]), "times": times})) check["fired"] += 1 if nxt is None: @@ -263,11 +283,11 @@ def take(self, body: Any = None) -> dict: check["next"] = nxt for note in notes: note["status"] = "delivered" - due.append((note["_at"], {"kind": "trigger", "id": note["id"], "channel": note["channel"], + due.append((note["_at"], {"kind": "trigger", "id": note["id"], "to": to, "channel": note["channel"], "text": note["text"], "at": note["at"], "trigger_id": note["trigger_id"]})) self._prune() due.sort(key=lambda d: (d[0] is not None, d[0])) - return {"items": [item for _, item in due], "until": _iso(until) if until else None} + return {"to": to, "items": [item for _, item in due], "until": _iso(until) if until else None} def _after(check: dict, until: datetime) -> tuple[int, datetime | None]: @@ -295,6 +315,23 @@ def _iso_duration(td: timedelta) -> str: 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("_")} @@ -304,21 +341,22 @@ def _tool_error(text: str) -> CallToolResult: 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.""" + """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) + 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() + return notifications.list(to=Caller.current().role) async def cancel_scheduled_check(check_id: str) -> dict | CallToolResult: try: - return notifications.cancel(check_id) + return notifications.cancel(check_id, to=Caller.current().role) except NotificationError as e: return _tool_error(str(e)) @@ -335,6 +373,14 @@ async def cancel_scheduled_check(check_id: str) -> dict | CallToolResult: 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(): diff --git a/src/agent_env/env/gateway/triggers.py b/src/agent_env/env/gateway/triggers.py index 1bef4c766..ff361be15 100644 --- a/src/agent_env/env/gateway/triggers.py +++ b/src/agent_env/env/gateway/triggers.py @@ -23,7 +23,7 @@ from mcp.types import CallToolResult, TextContent from .clock import ClockError, _advance, _iso, _parse_duration, _parse_rfc3339, _zone -from .notifications import validate_channel +from .notifications import validate_channel, validate_recipient from .caller import Caller from .constants import DEFAULT_ROLE, TRIGGER_IN_FLIGHT_STATUSES, TRIGGER_STATUSES @@ -745,6 +745,8 @@ def _normalize_action(action: Any, w: str, roots: tuple[str, ...], executors: di 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: @@ -767,6 +769,8 @@ def _normalize_action(action: Any, w: str, roots: tuple[str, ...], executors: di 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(): @@ -1379,9 +1383,11 @@ async def _run_action(self, tid: str, trig: dict, index: int, action: dict, ctx: except TemplateError as e: self._emit("action_failed", tid, action_index=index, detail=str(e)[:300]) return False - note = self._gw._notifications.notify(action["channel"], str(text), trigger_id=tid) + note = self._gw._notifications.notify(action["channel"], str(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"], "notification_id": note["id"], "text_len": len(str(text))} + detail = {"notify": action["channel"], "to": note["to"], "notification_id": note["id"], + "text_len": len(str(text))} else: if not await self._run_nl(tid, trig, index, action, ctx): return False 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 index 7e058896f..8f75ab2a5 100644 --- a/src/agent_env/task_step/task_steps/configure_scheduled_checks.py +++ b/src/agent_env/task_step/task_steps/configure_scheduled_checks.py @@ -1,9 +1,10 @@ -"""Task step that turns on the checks an agent can schedule for itself on a deployed env's gateway +"""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 the agent gets schedule_check, list_scheduled_checks and cancel_scheduled_check, on the env's virtual clock. -Place it after sync_env_clock (a check needs the clock) and before prompt_agent (the agent needs the tools on its -first turn). A harness takes what has come due from the gateway's notifications/v1 and puts it in front of the agent. +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). +A harness takes what has come due for a role and puts it in front of that role's agent. """ from __future__ import annotations @@ -31,21 +32,23 @@ def __init__( id: str, version: Optional[int], env_id: str, - scheduled_checks: bool = True, + 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, ): - """max_active caps the agent's checks still to come; min_interval is the shortest `every` it may ask for.""" + """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(scheduled_checks, bool): - raise ValueError("scheduled_checks must be a boolean") + 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.scheduled_checks = scheduled_checks + self.roles = roles self.max_active = max_active self.min_interval = min_interval self.timeout_seconds = timeout_seconds @@ -53,7 +56,7 @@ def __init__( def to_dict(self) -> dict: base = super().to_dict() base["env_id"] = self.env_id - base["scheduled_checks"] = self.scheduled_checks + base["roles"] = self.roles base["max_active"] = self.max_active base["min_interval"] = self.min_interval base["timeout_seconds"] = self.timeout_seconds @@ -64,7 +67,7 @@ def from_dict(cls, data: dict) -> ConfigureScheduledChecksTaskStep: return cls( **cls._base_from_dict(data), env_id=data["env_id"], - scheduled_checks=data.get("scheduled_checks", True), + 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), @@ -75,17 +78,16 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: 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 = {"scheduled_checks": self.scheduled_checks, "max_active": self.max_active, - "min_interval": self.min_interval} + 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}") + 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 ("scheduled_checks", "max_active", "min_interval")}, + "applied": {k: state.get(k) for k in ("roles", "max_active", "min_interval")}, }) return context diff --git a/tst/unit/env/gateway/notifications_test.py b/tst/unit/env/gateway/notifications_test.py index fc19bcc69..51ed1dc47 100644 --- a/tst/unit/env/gateway/notifications_test.py +++ b/tst/unit/env/gateway/notifications_test.py @@ -10,8 +10,9 @@ 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 GATEWAY_EXTENSIONS +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 @@ -39,7 +40,7 @@ def _frozen_clock(at: str = T0) -> Clock: def _notifications(clock: Clock | None = None, **config) -> Notifications: clock = clock or _frozen_clock() w = Notifications(lambda: clock) - w.configure({"scheduled_checks": True, **config}) + w.configure({"roles": ["default"], **config}) return w @@ -47,7 +48,7 @@ def _notifications(clock: Clock | None = None, **config) -> Notifications: def test_scheduled_checks_are_off_until_a_task_turns_them_on(): w = Notifications(lambda: _frozen_clock()) - assert w.state()["scheduled_checks"] is False + assert w.state()["roles"] == [] with pytest.raises(NotificationError, match="not available"): w.schedule("look again", at="PT6H") @@ -203,7 +204,7 @@ def test_turning_checks_off_cancels_the_ones_still_to_come(): clock = _frozen_clock() w = _notifications(clock) w.schedule("sweep", every="P1D") - w.configure({"scheduled_checks": False}) + 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"] @@ -227,11 +228,14 @@ def test_clear_turns_checks_off_and_forgets_everything(): w.notify("Gmail", "hello") w.clear() st = w.state() - assert (st["scheduled_checks"], st["checks"], st["pending_notifications"]) == (False, [], []) + assert (st["roles"], st["checks"], st["pending_notifications"]) == ([], [], []) @pytest.mark.parametrize("body, error", [ - ({"scheduled_checks": "yes"}, "boolean"), + ({"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"), ]) @@ -240,6 +244,74 @@ def test_bad_config_is_refused(body, 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"], i["note"]) for i in n.take({})["items"]] == [("default", "see if Dana replied")] + assert [(i["to"], i["note"]) 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 [i["text"] for i in n.take({})["items"]] == ["for the solver"] + assert [i["text"] 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 @@ -253,11 +325,12 @@ def gateway(tmp_path, monkeypatch) -> Gateway: gw._close_trajectory_file() -def _request(gw: Gateway, method: str, path: str, body: dict | None = None) -> httpx.Response: +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) + return await client.request(method, path, json=body, + headers={AGENT_ENV_ROLE_HEADER: role} if role else None) return asyncio.run(_do()) @@ -277,7 +350,9 @@ def test_the_card_names_the_extension_notifications_and_not_a2a_push_notificatio 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) - r = _request(gateway, "POST", "/notifications/config", {"max_active": 5}) + 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 @@ -285,8 +360,8 @@ def test_the_tools_appear_only_once_a_task_turns_them_on_and_go_on_clear(gateway def test_turning_checks_off_removes_the_tools(gateway): - _request(gateway, "POST", "/notifications/config", {}) - _request(gateway, "POST", "/notifications/config", {"scheduled_checks": False}) + _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) @@ -323,7 +398,7 @@ def test_a_bad_config_is_a_400(gateway): def test_the_agent_schedules_through_the_tool_and_the_harness_takes_it(gateway): - _request(gateway, "POST", "/notifications/config", {}) + _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"} @@ -337,12 +412,43 @@ def test_the_agent_schedules_through_the_tool_and_the_harness_takes_it(gateway): def test_list_is_read_only_so_it_does_not_re_check_state_triggers(gateway): - _request(gateway, "POST", "/notifications/config", {}) + _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"], i["note"]) for i in taken] == [("planner", "re-plan the week")] + + # ---- notify ---- class _FakeGateway: @@ -403,3 +509,34 @@ def test_notify_is_validated_at_registration(action, error): 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/test_configure_scheduled_checks.py b/tst/unit/task_step/test_configure_scheduled_checks.py index b7621b1fc..8702651e2 100644 --- a/tst/unit/task_step/test_configure_scheduled_checks.py +++ b/tst/unit/task_step/test_configure_scheduled_checks.py @@ -19,13 +19,15 @@ @pytest.mark.asyncio async def test_posts_the_config_and_records_what_the_gateway_applied(monkeypatch): - sent = _mock_gateway(monkeypatch, {"scheduled_checks": True, "max_active": 5, "min_interval": "PT1H"}) - out = await _step(max_active=5).execute(TaskStepContext(deployed_envs=[_record()], metadata={})) + 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) == {"scheduled_checks": True, "max_active": 5, "min_interval": "PT1H"} + assert json.loads(request.content) == applied [entry] = out.metadata["scheduled_check_configurations"] - assert entry["applied"] == {"scheduled_checks": True, "max_active": 5, "min_interval": "PT1H"} + assert entry == {"step_id": "checks-1", "env_id": "env-x", **applied, "applied": applied} @pytest.mark.asyncio @@ -48,18 +50,27 @@ async def test_a_refused_config_raises_with_the_gateway_s_reason(monkeypatch): 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.scheduled_checks) == (3, "PT2H", True) + 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}, {"scheduled_checks": "yes"}]) +@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", **kwargs) + return ConfigureScheduledChecksTaskStep(id="checks-1", version=None, env_id="env-x", + **{"roles": ["default"], **kwargs}) def _record(extensions: list = GATEWAY_EXTENSIONS) -> DeployedEnv: From 5cef82a417c90638ec71a8095177a538a6875ebe Mon Sep 17 00:00:00 2001 From: Sarah Chitty Date: Thu, 29 Oct 2026 10:56:59 -0700 Subject: [PATCH 08/14] feat(prompt_agent): deliver what came due on notifications/v1 at the start of the agent's next message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A scheduled check reached the agent only if the task's prompt_agent step listed a particular post-turn hook; a task that configured checks without it gave the agent checks that never arrived, and nothing warned about it. - After each turn the conversation goes on from, prompt_agent takes what came due for its agent's role (DeployedAgent.role, else "default") on every env whose card offers notifications/v1 take, and puts it at the start of the next message, oldest first, ahead of the user side's reply, which is unchanged. - Each item is one text part labelled with when and through what, as the #352 hook wrote them: "[Mon Feb 1, 3:00 PM · your scheduled check] " (with "(it came due N times while you were away)") and "[Mon Feb 1, 3:10 PM · Curbfinch alert] ". Each carries metadata.agentenv ({"source": "scheduled_check", "check_id", ...} or {"source": "trigger", "id", "trigger_id", ...}), so a verifier can tell delivered lines from the person's words in the one stored user turn. - A time_zone step param sets the labels' zone; UTC by default, stored only when set. - An answer that ends the conversation, the last turn and a failed turn take nothing: what is due stays queued. The user side is asked exactly as before, and the user sim never sees the delivered lines. - A post-turn hook may now return {"message": []} to say nothing: the agent then gets only what came due, and the conversation ends (nothing_to_send) if nothing has. An empty closing message on an end records nothing. - A take that keeps failing (three tries) fails the step rather than drop what is due. - The builder is task_step/task_steps/notification_delivery.py, for hooks to reuse; it documents the rules above. With nothing due, every message is what it was; the only addition is one take per turn on gateways that offer the extension. tst/integration/task_step/test_prompt_agent_notifications_local.py runs it on a real gateway with the next-env-event hook. Co-Authored-By: Claude Opus 5.5 --- src/agent_env/env/gateway/notifications.py | 5 +- .../task_steps/configure_scheduled_checks.py | 2 +- .../task_steps/notification_delivery.py | 119 +++++++++ .../task_step/task_steps/post_turn_hooks.py | 14 +- .../task_step/task_steps/prompt_agent.py | 28 +- .../test_prompt_agent_notifications_local.py | 87 +++++++ tst/unit/task_step/capture_stubs.py | 1 + .../test_prompt_agent_notifications.py | 240 ++++++++++++++++++ .../test_prompt_agent_post_turn_hooks.py | 17 +- 9 files changed, 499 insertions(+), 14 deletions(-) create mode 100644 src/agent_env/task_step/task_steps/notification_delivery.py create mode 100644 tst/integration/task_step/test_prompt_agent_notifications_local.py create mode 100644 tst/unit/task_step/test_prompt_agent_notifications.py diff --git a/src/agent_env/env/gateway/notifications.py b/src/agent_env/env/gateway/notifications.py index b4aa2485f..ab7cb5c52 100644 --- a/src/agent_env/env/gateway/notifications.py +++ b/src/agent_env/env/gateway/notifications.py @@ -6,8 +6,9 @@ - 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: a harness reads `state` to learn when the next one is due, may move the clock there, and -`take`s what has come due to put it in front of the agent. Unrelated to A2A push notifications. +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. """ from __future__ import annotations 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 index 8f75ab2a5..6b0d30e60 100644 --- a/src/agent_env/task_step/task_steps/configure_scheduled_checks.py +++ b/src/agent_env/task_step/task_steps/configure_scheduled_checks.py @@ -4,7 +4,7 @@ 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). -A harness takes what has come due for a role and puts it in front of that role's agent. +prompt_agent puts what has come due for its agent's role at the start of the agent's next message. """ from __future__ import annotations 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..48bd95a37 --- /dev/null +++ b/src/agent_env/task_step/task_steps/notification_delivery.py @@ -0,0 +1,119 @@ +"""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. + +Each item becomes one text part, labelled with when and through what, so nothing poses as the person: + + [Mon Feb 1, 3:00 PM · your scheduled check] See if Dana replied about the 2BR on Alder St + [Mon Feb 1, 3:10 PM · Curbfinch alert] New listing: 2BR on Ivy St, $3,050/mo + +and carries ``metadata.agentenv`` naming its source, 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 +from datetime import datetime, timezone +from typing import Any, Iterable, Optional +from zoneinfo import ZoneInfo + +from agent_env.env.gateway.constants import EXT_NOTIFICATIONS_URI + +DEFAULT_TIME_ZONE = "UTC" +_TAKE_ATTEMPTS = 3 +_EARLIEST = datetime.min.replace(tzinfo=timezone.utc) + + +def label_zone(name: Optional[str]) -> ZoneInfo: + """The IANA zone delivered lines show their times in; UTC when none is named.""" + try: + return ZoneInfo(name or DEFAULT_TIME_ZONE) + except Exception as e: # ZoneInfoNotFoundError, or a ValueError for a malformed key + raise ValueError(f"time_zone must be an IANA name such as 'America/Los_Angeles', got {name!r}") from e + + +def label_time(at: Optional[str], zone: ZoneInfo) -> str: + """'Mon Feb 1, 3:00 PM' in ``zone``; 'now' for an item with no time (queued while the env clock was unset).""" + if not at: + return "now" + local = _utc(at).astimezone(zone) + return f"{local.strftime('%a %b')} {local.day}, {local.strftime('%I:%M %p').lstrip('0')}" + + +def delivered_line(item: dict, zone: ZoneInfo) -> str: + """One taken item as the agent reads it.""" + when = label_time(item.get("at"), zone) + if item["kind"] == "scheduled_check": + times = item.get("times", 1) + again = f" (it came due {times} times while you were away)" if times > 1 else "" + return f"[{when} · your scheduled check] {item['note']}{again}" + return f"[{when} · {item['channel']}] {item['text']}" + + +def delivered_parts(items: Iterable[dict], zone: ZoneInfo) -> list[dict]: + """One text part per taken item, oldest first, each with ``metadata.agentenv``: ``{"source": "scheduled_check", + "check_id", "times", "at"}`` or ``{"source": "trigger", "id", "trigger_id", "channel", "at"}``, plus the + ``env_id`` it came from when the item carries one.""" + parts = [] + for item in sorted(items, key=_oldest_first): + if item["kind"] == "scheduled_check": + source = {"source": "scheduled_check", "check_id": item["check_id"], "times": item.get("times", 1)} + else: + source = {"source": "trigger", "id": item["id"], "trigger_id": item.get("trigger_id"), + "channel": item["channel"]} + meta = {**source, "at": item.get("at"), **({"env_id": item["env_id"]} if item.get("env_id") else {})} + parts.append({"kind": "text", "text": delivered_line(item, zone), "metadata": {"agentenv": meta}}) + 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. An env that keeps failing raises: a due item silently dropped is worse.""" + envs = [env for env in envs if env.supports(EXT_NOTIFICATIONS_URI, "take")] + taken = await asyncio.gather(*(_take(env, to) for env in envs)) + 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 + for attempt in range(_TAKE_ATTEMPTS): + try: + return await env.invoke(EXT_NOTIFICATIONS_URI, "take", {"to": to}) + except Exception as e: + last = e + if attempt + 1 < _TAKE_ATTEMPTS: + await asyncio.sleep(0.5 * (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..7b634eee7 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 @@ -221,25 +221,29 @@ def heartbeat(self) -> None: 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..dab9b211b 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, label_zone, 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, @@ -170,6 +177,7 @@ def __init__( snapshot_config: Optional[SnapshotConfig] = None, live_trajectory: bool = False, post_turn_hooks: Optional[list[dict]] = None, + time_zone: Optional[str] = None, ): # `prompt` is the legacy text-only field; `parts` is the multimodal # form that's the authoritative A2A wire shape. Exactly one must be set: @@ -185,6 +193,7 @@ def __init__( raise ValueError("max_conversation_turns must be >= 1") if post_turn_hooks is not None: validate_hook_specs(post_turn_hooks) + label_zone(time_zone) # the zone delivered notifications are labelled in; refused here if it isn't one if model_params: reserved = set(model_params) & MODEL_PARAMS_RESERVED if reserved: @@ -216,6 +225,7 @@ def __init__( self.snapshot_config = snapshot_config self.live_trajectory = live_trajectory self.post_turn_hooks = post_turn_hooks + self.time_zone = time_zone @staticmethod def _validate_parts(parts: list[dict]) -> None: @@ -293,6 +303,8 @@ def to_dict(self) -> dict: base["live_trajectory"] = True if self.post_turn_hooks is not None: base["post_turn_hooks"] = self.post_turn_hooks + if self.time_zone is not None: + base["time_zone"] = self.time_zone return base @classmethod @@ -330,6 +342,7 @@ def from_dict(cls, data: dict) -> PromptAgentTaskStep: ), live_trajectory=data.get("live_trajectory", False), post_turn_hooks=data.get("post_turn_hooks"), + time_zone=data.get("time_zone"), ) def _trajectory_prefix(self) -> str: @@ -541,6 +554,10 @@ 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 + zone = label_zone(self.time_zone) 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 +686,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, zone), 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/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..88e1f5ade --- /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, time_zone="America/Los_Angeles", + 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, 7:40 AM · your scheduled check] look for new rugs", + "[Tue Mar 3, 8:40 AM · 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"]["trigger_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/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_prompt_agent_notifications.py b/tst/unit/task_step/test_prompt_agent_notifications.py new file mode 100644 index 000000000..23dcd7d78 --- /dev/null +++ b/tst/unit/task_step/test_prompt_agent_notifications.py @@ -0,0 +1,240 @@ +"""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, label_zone, 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) + 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(time_zone="America/Los_Angeles").execute(ctx) + + assert _to_agent(ctx)[1] == [ + {"kind": "text", "text": f"[Mon Feb 1, 3:00 PM · your scheduled check] {ALDER}", + "metadata": {"agentenv": {"source": "scheduled_check", "check_id": "check-1", "times": 1, + "at": "2027-02-01T23:00:00Z", "env_id": "env"}}}, + {"kind": "text", "text": f"[Mon Feb 1, 3:10 PM · Curbfinch alert] {IVY}", + "metadata": {"agentenv": {"source": "trigger", "id": "notification-2", "trigger_id": "ivy-listed", + "channel": "Curbfinch alert", "at": "2027-02-01T23:10:00Z", "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_unless_the_step_names_a_time_zone(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 · your scheduled check] {ALDER}", + f"[Mon Feb 1, 11:10 PM · 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 · 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 · your scheduled check] {ALDER}", f"[Mon Feb 1, 11:10 PM · 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 · 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_round_trips_is_stored_only_when_set_and_is_checked_when_built(): + assert "time_zone" not in _step().to_dict() + stored = _step(time_zone="America/Los_Angeles").to_dict() + assert PromptAgentTaskStep.from_dict(stored).time_zone == "America/Los_Angeles" + with pytest.raises(ValueError, match="IANA name"): + _step(time_zone="Pacific Time") + + +def test_a_check_that_came_due_more_than_once_says_so(): + item = {"kind": "scheduled_check", "check_id": "check-1", "note": "check rugs", "at": MONDAY, "times": 4} + assert delivered_line(item, label_zone(None)) == ( + "[Mon Feb 1, 3:00 PM · your scheduled check] check rugs (it came due 4 times while you were away)") + queued_unclocked = {"kind": "trigger", "id": "n", "channel": "Gmail", "text": "hi", "at": None} + assert delivered_line(queued_unclocked, label_zone(None)) == "[now · Gmail] hi" + + +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")] 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..f8cdbc559 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"}, 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 From 2faab848d401151268deec7a6ca74200a83c4f68 Mon Sep 17 00:00:00 2001 From: Sarah Chitty Date: Thu, 29 Oct 2026 11:09:58 -0700 Subject: [PATCH 09/14] fix(notifications): a retried take gets the same batch back; name the retry delay - take accepts a take_id; the same id for the same recipient returns the stored batch, so a retry after a lost response can't come back empty (the gateway keeps the last 64) - prompt_agent sends one take_id per env per take, reused across its retries, and asks every env before failing - _TAKE_RETRY_BASE_DELAY_S names the retry delay Co-Authored-By: Claude Opus 5.5 --- src/agent_env/env/gateway/notifications.py | 24 +++++++++++-- .../task_steps/notification_delivery.py | 16 ++++++--- tst/unit/env/gateway/notifications_test.py | 12 +++++++ .../test_prompt_agent_notifications.py | 34 +++++++++++++++++++ .../test_prompt_agent_post_turn_hooks.py | 2 +- .../task_step/test_prompt_agent_user_side.py | 4 +-- 6 files changed, 82 insertions(+), 10 deletions(-) diff --git a/src/agent_env/env/gateway/notifications.py b/src/agent_env/env/gateway/notifications.py index ab7cb5c52..4486a01db 100644 --- a/src/agent_env/env/gateway/notifications.py +++ b/src/agent_env/env/gateway/notifications.py @@ -34,6 +34,8 @@ _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 @@ -93,6 +95,7 @@ def __init__(self, clock: Callable[[], Clock]): 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 @@ -136,6 +139,7 @@ def clear(self) -> dict: self._roles = () self._checks.clear() self._queue.clear() + self._takes.clear() return {"ok": True} @property @@ -262,9 +266,18 @@ def _prune(self) -> None: def take(self, body: Any = None) -> dict: """Everything due for the recipient `to` (default: the solver's role) by `until` (default: now), oldest first; each is handed out once. A repeating check moves to its next time after `until` and says how many - times it came due, so a long jump wakes the agent once.""" + times it came due, 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]] = [] @@ -287,8 +300,13 @@ def take(self, body: Any = None) -> dict: due.append((note["_at"], {"kind": "trigger", "id": note["id"], "to": to, "channel": note["channel"], "text": note["text"], "at": note["at"], "trigger_id": note["trigger_id"]})) self._prune() - due.sort(key=lambda d: (d[0] is not None, d[0])) - return {"to": to, "items": [item for _, item in due], "until": _iso(until) if until else None} + 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 _after(check: dict, until: datetime) -> tuple[int, datetime | None]: diff --git a/src/agent_env/task_step/task_steps/notification_delivery.py b/src/agent_env/task_step/task_steps/notification_delivery.py index 48bd95a37..f27e905be 100644 --- a/src/agent_env/task_step/task_steps/notification_delivery.py +++ b/src/agent_env/task_step/task_steps/notification_delivery.py @@ -23,6 +23,7 @@ from __future__ import annotations import asyncio +import uuid from datetime import datetime, timezone from typing import Any, Iterable, Optional from zoneinfo import ZoneInfo @@ -31,6 +32,7 @@ DEFAULT_TIME_ZONE = "UTC" _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) @@ -91,22 +93,28 @@ def says_nothing(parts: list[dict]) -> bool: 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. An env that keeps failing raises: a due item silently dropped is worse.""" + 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)) + 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", {"to": to}) + return await env.invoke(EXT_NOTIFICATIONS_URI, "take", body) except Exception as e: last = e if attempt + 1 < _TAKE_ATTEMPTS: - await asyncio.sleep(0.5 * (attempt + 1)) + 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}") diff --git a/tst/unit/env/gateway/notifications_test.py b/tst/unit/env/gateway/notifications_test.py index 51ed1dc47..32b06d401 100644 --- a/tst/unit/env/gateway/notifications_test.py +++ b/tst/unit/env/gateway/notifications_test.py @@ -180,6 +180,18 @@ def test_take_until_looks_ahead_without_moving_the_clock(): assert [i["note"] for i in w.take({"until": "PT3H"})["items"]] == ["later"] +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 [i["text"] 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 diff --git a/tst/unit/task_step/test_prompt_agent_notifications.py b/tst/unit/task_step/test_prompt_agent_notifications.py index 23dcd7d78..50157218e 100644 --- a/tst/unit/task_step/test_prompt_agent_notifications.py +++ b/tst/unit/task_step/test_prompt_agent_notifications.py @@ -42,6 +42,7 @@ 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"}) @@ -238,3 +239,36 @@ def test_a_blank_reply_adds_nothing_to_delivered_lines_and_without_them_is_sent_ 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["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 f8cdbc559..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 @@ -554,7 +554,7 @@ def run(full_card: bool): 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"}, 30)] + ("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): From 22c5d7710cf8f0c4026b0b4dad8d9af486651e45 Mon Sep 17 00:00:00 2001 From: Sarah Chitty Date: Thu, 29 Oct 2026 11:48:49 -0700 Subject: [PATCH 10/14] refactor(notifications): every item take returns has one shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A scheduled check and a trigger notification were two shapes told apart by kind, so every consumer branched on it, and a third kind would have broken any that treated "not a check" as a notification. - take returns every item as {id, to, at, times, source, source_id, channel, parts}: source is "scheduled_check" or "trigger", source_id the check's or the trigger's id, channel the label the agent sees ("your scheduled check", or the trigger's channel), parts the note or the trigger's text as one text part. times is 1 for a notification. state is unchanged: checks and pending_notifications keep their own bookkeeping. - The delivery builder no longer branches on the source: "[when · {channel}]" plus the item's text, with "(it came due N times while you were away)" when times > 1. Each delivered part's metadata.agentenv is the item without its parts (source and source_id among them, plus env_id); a part that isn't text would follow the line as it is. - validate_channel refuses "your scheduled check" (any case), so a trigger can't pass itself off as the agent's own reminder. - The card's description gives the item shape. Co-Authored-By: Claude Opus 5.5 --- src/agent_env/env/gateway/constants.py | 2 +- src/agent_env/env/gateway/notifications.py | 32 ++++++++++--- .../task_steps/notification_delivery.py | 36 +++++++-------- .../test_prompt_agent_notifications_local.py | 2 +- tst/unit/env/gateway/notifications_test.py | 45 ++++++++++++++----- .../test_prompt_agent_notifications.py | 37 +++++++++++---- 6 files changed, 106 insertions(+), 48 deletions(-) diff --git a/src/agent_env/env/gateway/constants.py b/src/agent_env/env/gateway/constants.py index 8b9bf81dd..069363269 100644 --- a/src/agent_env/env/gateway/constants.py +++ b/src/agent_env/env/gateway/constants.py @@ -249,7 +249,7 @@ def random_mcp_server_name() -> str: }, { "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). Unrelated to A2A push notifications.", + "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. 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}, diff --git a/src/agent_env/env/gateway/notifications.py b/src/agent_env/env/gateway/notifications.py index 4486a01db..6edaa9124 100644 --- a/src/agent_env/env/gateway/notifications.py +++ b/src/agent_env/env/gateway/notifications.py @@ -9,6 +9,10 @@ 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 @@ -29,6 +33,9 @@ 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 @@ -265,9 +272,10 @@ def _prune(self) -> None: def take(self, body: Any = None) -> dict: """Everything due for the recipient `to` (default: the solver's role) by `until` (default: now), oldest - first; each is handed out once. A repeating check moves to its next time after `until` and says how many - times it came due, 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.""" + 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") @@ -288,8 +296,8 @@ def take(self, body: Any = None) -> dict: 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"], {"kind": "scheduled_check", "check_id": check["check_id"], "to": to, - "note": check["note"], "at": _iso(check["next"]), "times": times})) + 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" @@ -297,8 +305,8 @@ def take(self, body: Any = None) -> dict: check["next"] = nxt for note in notes: note["status"] = "delivered" - due.append((note["_at"], {"kind": "trigger", "id": note["id"], "to": to, "channel": note["channel"], - "text": note["text"], "at": note["at"], "trigger_id": note["trigger_id"]})) + 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} @@ -309,6 +317,13 @@ def take(self, body: Any = None) -> dict: 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.""" @@ -406,3 +421,6 @@ def validate_channel(channel: Any, where: str) -> None: 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/task_step/task_steps/notification_delivery.py b/src/agent_env/task_step/task_steps/notification_delivery.py index f27e905be..b2f9491c8 100644 --- a/src/agent_env/task_step/task_steps/notification_delivery.py +++ b/src/agent_env/task_step/task_steps/notification_delivery.py @@ -1,13 +1,16 @@ """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. -Each item becomes one text part, labelled with when and through what, so nothing poses as the person: +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 · your scheduled check] See if Dana replied about the 2BR on Alder St [Mon Feb 1, 3:10 PM · Curbfinch alert] New listing: 2BR on Ivy St, $3,050/mo -and carries ``metadata.agentenv`` naming its source, so a verifier reading the stored conversation can tell a -delivered line from what the person wrote in the same message. +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 @@ -53,28 +56,23 @@ def label_time(at: Optional[str], zone: ZoneInfo) -> str: def delivered_line(item: dict, zone: ZoneInfo) -> str: - """One taken item as the agent reads it.""" + """A taken item's text as the agent reads it: ``[when · channel]``, then its text parts.""" when = label_time(item.get("at"), zone) - if item["kind"] == "scheduled_check": - times = item.get("times", 1) - again = f" (it came due {times} times while you were away)" if times > 1 else "" - return f"[{when} · your scheduled check] {item['note']}{again}" - return f"[{when} · {item['channel']}] {item['text']}" + 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], zone: ZoneInfo) -> list[dict]: - """One text part per taken item, oldest first, each with ``metadata.agentenv``: ``{"source": "scheduled_check", - "check_id", "times", "at"}`` or ``{"source": "trigger", "id", "trigger_id", "channel", "at"}``, plus the - ``env_id`` it came from when the item carries one.""" + """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): - if item["kind"] == "scheduled_check": - source = {"source": "scheduled_check", "check_id": item["check_id"], "times": item.get("times", 1)} - else: - source = {"source": "trigger", "id": item["id"], "trigger_id": item.get("trigger_id"), - "channel": item["channel"]} - meta = {**source, "at": item.get("at"), **({"env_id": item["env_id"]} if item.get("env_id") else {})} - parts.append({"kind": "text", "text": delivered_line(item, zone), "metadata": {"agentenv": meta}}) + meta = {"agentenv": {k: v for k, v in item.items() if k != "parts"}} + parts.append({"kind": "text", "text": delivered_line(item, zone), "metadata": meta}) + parts.extend({**p, "metadata": meta} for p in item["parts"] if p.get("kind") != "text") return parts diff --git a/tst/integration/task_step/test_prompt_agent_notifications_local.py b/tst/integration/task_step/test_prompt_agent_notifications_local.py index 88e1f5ade..885340d48 100644 --- a/tst/integration/task_step/test_prompt_agent_notifications_local.py +++ b/tst/integration/task_step/test_prompt_agent_notifications_local.py @@ -81,7 +81,7 @@ async def test_what_came_due_for_the_solver_opens_its_next_message_on_a_real_gat ] assert [p.get("metadata", {}).get("agentenv", {}).get("source") for p in sent[1]] == [ "scheduled_check", "trigger", None] - assert sent[1][1]["metadata"]["agentenv"]["trigger_id"] == "ivy-listed" + 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/env/gateway/notifications_test.py b/tst/unit/env/gateway/notifications_test.py index 32b06d401..04e2fc030 100644 --- a/tst/unit/env/gateway/notifications_test.py +++ b/tst/unit/env/gateway/notifications_test.py @@ -44,6 +44,12 @@ def _notifications(clock: Clock | None = None, **config) -> Notifications: 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(): @@ -118,7 +124,7 @@ def test_take_hands_out_each_due_check_once_and_a_one_shot_is_done(): 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["kind"], i["note"], i["at"]) for i in taken] == [("scheduled_check", "look at 9", "2027-02-01T17:00:00Z")] + 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" @@ -177,7 +183,23 @@ def test_take_until_looks_ahead_without_moving_the_clock(): w = _notifications() w.schedule("later", at="PT3H") assert w.take({"until": "PT1H"})["items"] == [] - assert [i["note"] for i in w.take({"until": "PT3H"})["items"]] == ["later"] + 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(): @@ -185,7 +207,7 @@ def test_a_take_retried_with_its_take_id_gets_the_same_batch_back(): n = Notifications(lambda: clock) n.notify("Messages", "Jamie: did the rug thing get sorted?") first = n.take({"take_id": "t-1"}) - assert [i["text"] for i in first["items"]] == ["Jamie: did the rug thing get sorted?"] + 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"): @@ -199,7 +221,7 @@ def test_notifications_queue_at_the_virtual_time_and_are_taken_once(): 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["kind"], item["text"]) == ("trigger", "Jamie: did the rug thing get sorted?") + assert (item["source"], _said(item)) == ("trigger", "Jamie: did the rug thing get sorted?") assert w.take({})["items"] == [] and w.state()["pending_notifications"] == [] @@ -209,7 +231,7 @@ def test_due_is_judged_on_the_time_not_its_text(): 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 [i.get("text") or i.get("note") for i in items] == ["on the second", "half a second on"] + 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(): @@ -277,8 +299,8 @@ def test_a_persona_s_check_never_reaches_the_solver(): 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"], i["note"]) for i in n.take({})["items"]] == [("default", "see if Dana replied")] - assert [(i["to"], i["note"]) for i in n.take({"to": "landlord"})["items"]] == [("landlord", "chase the deposit")] + 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(): @@ -314,8 +336,8 @@ 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 [i["text"] for i in n.take({})["items"]] == ["for the solver"] - assert [i["text"] for i in n.take({"to": "landlord"})["items"]] == ["for the 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(" ")]) @@ -420,7 +442,7 @@ def test_the_agent_schedules_through_the_tool_and_the_harness_takes_it(gateway): 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 [i["note"] for i in items] == ["check rugs"] + assert [_said(i) for i in items] == ["check rugs"] def test_list_is_read_only_so_it_does_not_re_check_state_triggers(gateway): @@ -458,7 +480,7 @@ def test_a_check_is_for_the_role_that_scheduled_it_and_only_its_take_hands_it_ou 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"], i["note"]) for i in taken] == [("planner", "re-plan the week")] + assert [(i["to"], _said(i)) for i in taken] == [("planner", "re-plan the week")] # ---- notify ---- @@ -509,6 +531,7 @@ async def test_notify_queues_a_templated_notification(): @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), diff --git a/tst/unit/task_step/test_prompt_agent_notifications.py b/tst/unit/task_step/test_prompt_agent_notifications.py index 50157218e..169185e7d 100644 --- a/tst/unit/task_step/test_prompt_agent_notifications.py +++ b/tst/unit/task_step/test_prompt_agent_notifications.py @@ -92,11 +92,13 @@ async def test_what_came_due_opens_the_next_message_ahead_of_the_user_sims_reply assert _to_agent(ctx)[1] == [ {"kind": "text", "text": f"[Mon Feb 1, 3:00 PM · your scheduled check] {ALDER}", - "metadata": {"agentenv": {"source": "scheduled_check", "check_id": "check-1", "times": 1, - "at": "2027-02-01T23:00:00Z", "env_id": "env"}}}, + "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, 3:10 PM · Curbfinch alert] {IVY}", - "metadata": {"agentenv": {"source": "trigger", "id": "notification-2", "trigger_id": "ivy-listed", - "channel": "Curbfinch alert", "at": "2027-02-01T23:10:00Z", "env_id": "env"}}}, + "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 @@ -226,12 +228,29 @@ def test_time_zone_round_trips_is_stored_only_when_set_and_is_checked_when_built _step(time_zone="Pacific Time") -def test_a_check_that_came_due_more_than_once_says_so(): - item = {"kind": "scheduled_check", "check_id": "check-1", "note": "check rugs", "at": MONDAY, "times": 4} - assert delivered_line(item, label_zone(None)) == ( +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, label_zone(None)) == ( "[Mon Feb 1, 3:00 PM · your scheduled check] check rugs (it came due 4 times while you were away)") - queued_unclocked = {"kind": "trigger", "id": "n", "channel": "Gmail", "text": "hi", "at": None} + queued_unclocked = _item("trigger", "Gmail", "hi", at=None) assert delivered_line(queued_unclocked, label_zone(None)) == "[now · Gmail] hi" + unknown_source = _item("calendar", "Calendar", "Standup in 10 minutes") + assert delivered_line(unknown_source, label_zone(None)) == "[Mon Feb 1, 3:00 PM · 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], label_zone(None)) == [ + {"kind": "text", "text": "[Mon Feb 1, 3:00 PM · 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(): @@ -270,5 +289,5 @@ async def test_a_take_retried_after_a_lost_response_still_delivers_the_item(monk n.notify("Messages", "Jamie: did the rug thing get sorted?") env = _FlakyEnv(n) items = await notification_delivery.take_due([env], "default") - assert [i["text"] for i in items] == ["Jamie: did the rug thing get sorted?"] + 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"] From eea2c957a4ad4967e26002e00c5c1a5efa92e835 Mon Sep 17 00:00:00 2001 From: Sarah Chitty Date: Thu, 29 Oct 2026 11:50:59 -0700 Subject: [PATCH 11/14] fix(notifications): schedule_check states its real rules; time errors name the agent's argument The descriptions left out rules the agent only learned from errors, and "ISO 8601 duration" promised more than the parser takes (P1M, P1Y and PT1.5H are all refused). A bad time's error named the clock's virtual_time, a field the agent never sees. - 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)." - every: "Repeat this often: a duration in weeks, days, hours, minutes or seconds (PT6H, P1D, P1W), at least {min_interval}. No months or years; P1D is exactly 24 hours, not the same local time each day." - until: "Only with every: stop after this time, or after this duration from now (not from at)." - The tool description ends "You can have at most {max_active} checks at once. Times you get back are in UTC." - The limits come from the config, so each config rebuilds the tools (and their mirrored descriptors) instead of keeping the first ones it registered. - _when and _duration give their own errors, naming at, every or until and the rule broken: a time needs seconds and a UTC offset; a duration is in capitals, with no months or years, and only seconds may have a fraction. A leading lowercase p is read as a duration, so pt6h gets a duration error. A duration of the right shape that is too big to count is "too long". Co-Authored-By: Claude Opus 5.5 --- src/agent_env/env/gateway/gateway.py | 6 +- src/agent_env/env/gateway/notifications.py | 86 +++++++++++++++------- tst/unit/env/gateway/notifications_test.py | 45 ++++++++++- 3 files changed, 107 insertions(+), 30 deletions(-) diff --git a/src/agent_env/env/gateway/gateway.py b/src/agent_env/env/gateway/gateway.py index 08d5ccf35..599619ed6 100644 --- a/src/agent_env/env/gateway/gateway.py +++ b/src/agent_env/env/gateway/gateway.py @@ -1207,11 +1207,13 @@ def _unregister_get_time_tool(self) -> None: self._trigger_engine.invalidate_readonly_cache() def _register_check_tools(self) -> None: - """Expose schedule_check and its siblings (idempotent). Called when a task turns scheduled checks on.""" + """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.setdefault(tool.name, tool) + tools[tool.name] = tool if self._mirror_check_descriptors(): self._trigger_engine.invalidate_readonly_cache() diff --git a/src/agent_env/env/gateway/notifications.py b/src/agent_env/env/gateway/notifications.py index 6edaa9124..4d2d8d809 100644 --- a/src/agent_env/env/gateway/notifications.py +++ b/src/agent_env/env/gateway/notifications.py @@ -25,7 +25,7 @@ from mcp.types import CallToolResult, TextContent, ToolAnnotations from .caller import Caller -from .clock import Clock, ClockError, _iso, _parse_duration, _parse_rfc3339 +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" @@ -51,20 +51,31 @@ "(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." + "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." ) -_SCHEDULE_SCHEMA: dict[str, Any] = { - "type": "object", - "properties": { - "note": {"type": "string", "description": "What to do when the check comes due."}, - "at": {"type": "string", "description": "When: an RFC 3339 time with its offset (2027-02-02T18:00:00-08:00), " - "or an ISO 8601 duration from now (PT6H)."}, - "every": {"type": "string", "description": "Repeat this often: an ISO 8601 duration (PT6H, P1D)."}, - "until": {"type": "string", "description": "Stop repeating after this time (RFC 3339) or duration from now."}, - }, - "required": ["note"], - "additionalProperties": False, -} +_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", @@ -79,15 +90,29 @@ class NotificationError(ValueError): def _when(text: Any, now: datetime, field: str) -> datetime: - """An RFC 3339 instant, or an ISO 8601 duration counted from now.""" - if not isinstance(text, str) or not text: - raise NotificationError(f"{field} must be an RFC 3339 time or an ISO 8601 duration") + """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 now + _parse_duration(text) if text.startswith("P") else _parse_rfc3339(text) - except ClockError as e: - raise NotificationError(f"{field}: {e}") - except OverflowError: - raise NotificationError(f"{field} is too far away") + 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: @@ -109,6 +134,14 @@ def __init__(self, clock: Callable[[], Clock]): 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.""" @@ -177,10 +210,7 @@ def schedule(self, note: Any, at: Any = None, every: Any = None, until: Any = No now = self._now() step = None if every is not None: - try: - step = _parse_duration(every) - except (ClockError, OverflowError) as e: - raise NotificationError(f"every: {e}") + 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: @@ -395,7 +425,9 @@ async def cancel_scheduled_check(check_id: str) -> dict | CallToolResult: return _tool_error(str(e)) specs = [ - (SCHEDULE_CHECK_TOOL_NAME, schedule_check, SCHEDULE_CHECK_DESCRIPTION, _SCHEDULE_SCHEMA, False), + (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.", diff --git a/tst/unit/env/gateway/notifications_test.py b/tst/unit/env/gateway/notifications_test.py index 04e2fc030..dad972ed7 100644 --- a/tst/unit/env/gateway/notifications_test.py +++ b/tst/unit/env/gateway/notifications_test.py @@ -80,7 +80,7 @@ def test_schedule_returns_what_the_gateway_decided_and_list_still_shows_the_note ({"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"}, "RFC 3339|RFC3339"), + ({"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"), @@ -92,6 +92,22 @@ def test_bad_checks_are_refused_with_the_reason(kwargs, 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") @@ -393,6 +409,33 @@ def test_the_tools_appear_only_once_a_task_turns_them_on_and_go_on_clear(gateway assert not set(CHECK_TOOL_NAMES) & set(tools) and not set(CHECK_TOOL_NAMES) & _gateway_tool_names(gateway) +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": []}) From f0b5b023e98a94666d829418d5a5573a1db01318 Mon Sep 17 00:00:00 2001 From: Sarah Chitty Date: Thu, 29 Oct 2026 11:52:21 -0700 Subject: [PATCH 12/14] fix(notifications): say how config applies settings; clear resets them; turning checks off is never refused - config replaces every setting: a field left out goes back to its default, so {"max_active": 5} on its own turns checks off (roles defaults to []). The card's description and configure's docstring say so; the configure_scheduled_checks step already sends every field. - A new config 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. Also in the description, and tested. - A config that turns checks off (roles: [], or left out) registers nothing, so it skips the tool-discovery wait and the 409 for a backing server that already serves one of the tool names; neither can block it any more. - clear resets roles, max_active and min_interval to their defaults as well as dropping every check and notification, so state afterwards shows the defaults rather than the last config's limits. Co-Authored-By: Claude Opus 5.5 --- src/agent_env/env/gateway/constants.py | 2 +- src/agent_env/env/gateway/gateway.py | 33 ++++++++++----- src/agent_env/env/gateway/notifications.py | 9 +++- .../task_steps/configure_scheduled_checks.py | 3 +- tst/unit/env/gateway/notifications_test.py | 42 ++++++++++++++++--- 5 files changed, 68 insertions(+), 21 deletions(-) diff --git a/src/agent_env/env/gateway/constants.py b/src/agent_env/env/gateway/constants.py index 069363269..c20c5de23 100644 --- a/src/agent_env/env/gateway/constants.py +++ b/src/agent_env/env/gateway/constants.py @@ -249,7 +249,7 @@ def random_mcp_server_name() -> str: }, { "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. Unrelated to A2A push notifications.", + "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}, diff --git a/src/agent_env/env/gateway/gateway.py b/src/agent_env/env/gateway/gateway.py index 599619ed6..e620645c3 100644 --- a/src/agent_env/env/gateway/gateway.py +++ b/src/agent_env/env/gateway/gateway.py @@ -328,21 +328,32 @@ async def _notifications_write(request: Request, method) -> Response: @self._mcp.custom_route("/notifications/config", methods=["POST"]) async def notifications_config(request: Request) -> Response: - try: # the backing servers' tools must be known, or a later discovery would replace ours unseen - 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) - response = await _notifications_write(request, self._notifications.configure) + 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 response + return self._json_response(state, 200) @self._mcp.custom_route("/notifications/state", methods=["GET"]) async def notifications_state(request: Request) -> Response: diff --git a/src/agent_env/env/gateway/notifications.py b/src/agent_env/env/gateway/notifications.py index 4d2d8d809..a50a0bff4 100644 --- a/src/agent_env/env/gateway/notifications.py +++ b/src/agent_env/env/gateway/notifications.py @@ -149,7 +149,10 @@ def enabled(self) -> bool: 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.""" + 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)) @@ -175,8 +178,10 @@ def configure(self, body: Any) -> dict: 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._roles, self._max_active = (), DEFAULT_MAX_ACTIVE + self._min_interval = _parse_duration(DEFAULT_MIN_INTERVAL) self._checks.clear() self._queue.clear() self._takes.clear() 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 index 6b0d30e60..aa46f4f5c 100644 --- a/src/agent_env/task_step/task_steps/configure_scheduled_checks.py +++ b/src/agent_env/task_step/task_steps/configure_scheduled_checks.py @@ -4,7 +4,8 @@ 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. +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 diff --git a/tst/unit/env/gateway/notifications_test.py b/tst/unit/env/gateway/notifications_test.py index dad972ed7..a957bc249 100644 --- a/tst/unit/env/gateway/notifications_test.py +++ b/tst/unit/env/gateway/notifications_test.py @@ -272,13 +272,31 @@ def test_history_is_bounded_and_delivered_notifications_are_dropped(): assert w._queue == [] -def test_clear_turns_checks_off_and_forgets_everything(): - w = _notifications() +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["checks"], st["pending_notifications"]) == ([], [], []) + 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", [ @@ -407,6 +425,8 @@ def test_the_tools_appear_only_once_a_task_turns_them_on_and_go_on_clear(gateway 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): @@ -444,10 +464,19 @@ def test_turning_checks_off_removes_the_tools(gateway): 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", {}) + 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 @@ -456,7 +485,7 @@ async def discover(): gateway._tools_discovered = True monkeypatch.setattr(gateway, "_ensure_tools_discovered", discover) - r = _request(gateway, "POST", "/notifications/config", {}) + r = _request(gateway, "POST", "/notifications/config", {"roles": ["default"]}) assert r.status_code == 409 and "schedule_check" in r.json()["error"] @@ -465,9 +494,10 @@ async def discover(): raise RuntimeError("backing server down") monkeypatch.setattr(gateway, "_ensure_tools_discovered", discover) - r = _request(gateway, "POST", "/notifications/config", {}) + 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): From d76448f7f536f807d83b08db93d6928607277101 Mon Sep 17 00:00:00 2001 From: Sarah Chitty Date: Thu, 29 Oct 2026 16:08:50 -0700 Subject: [PATCH 13/14] fix(triggers): a notify whose text resolves to nothing queues no notification A whole-value optional placeholder such as ${result.message?} used to reach the agent as literal text when the field was missing. The text now resolves inside an object, so it drops, and an empty text skips the notification (action_ok records why). Co-Authored-By: Claude Opus 5.5 --- src/agent_env/env/gateway/triggers.py | 16 ++++++++++------ tst/unit/env/gateway/notifications_test.py | 13 +++++++++++++ 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/src/agent_env/env/gateway/triggers.py b/src/agent_env/env/gateway/triggers.py index ff361be15..1017d3ef3 100644 --- a/src/agent_env/env/gateway/triggers.py +++ b/src/agent_env/env/gateway/triggers.py @@ -1379,15 +1379,19 @@ async def _run_action(self, tid: str, trig: dict, index: int, action: dict, ctx: detail = {"tool": action["tool"], "args": _echo_args(args), **acting} elif action["type"] == "notify": try: - text = _template(action["text"], {}, ctx) + # 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 - note = self._gw._notifications.notify(action["channel"], str(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(str(text))} + 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/tst/unit/env/gateway/notifications_test.py b/tst/unit/env/gateway/notifications_test.py index a957bc249..afb30743c 100644 --- a/tst/unit/env/gateway/notifications_test.py +++ b/tst/unit/env/gateway/notifications_test.py @@ -602,6 +602,19 @@ async def test_notify_queues_a_templated_notification(): 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"), From 70b7095e613db10d8c7cffdcda285b82b37de9a7 Mon Sep 17 00:00:00 2001 From: Sarah Chitty Date: Fri, 30 Oct 2026 10:28:00 -0700 Subject: [PATCH 14/14] refactor(prompt_agent): drop time_zone; delivered lines are labelled in UTC and say so MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everything else the agent reads about time is UTC (get_time, schedule_check), and the label didn't name its zone, so 11:00 PM could be read as local. Lines now read [Mon Feb 1, 11:00 PM UTC · your scheduled check] ... (Edgar's review). Co-Authored-By: Claude Opus 5.5 --- .../task_steps/notification_delivery.py | 32 ++++++--------- .../task_step/task_steps/prompt_agent.py | 11 +---- .../test_prompt_agent_notifications_local.py | 6 +-- .../test_prompt_agent_notifications.py | 41 +++++++++---------- 4 files changed, 36 insertions(+), 54 deletions(-) diff --git a/src/agent_env/task_step/task_steps/notification_delivery.py b/src/agent_env/task_step/task_steps/notification_delivery.py index b2f9491c8..51d335f54 100644 --- a/src/agent_env/task_step/task_steps/notification_delivery.py +++ b/src/agent_env/task_step/task_steps/notification_delivery.py @@ -5,8 +5,10 @@ ``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 · your scheduled check] See if Dana replied about the 2BR on Alder St - [Mon Feb 1, 3:10 PM · Curbfinch alert] New listing: 2BR on Ivy St, $3,050/mo + [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 @@ -29,49 +31,39 @@ import uuid from datetime import datetime, timezone from typing import Any, Iterable, Optional -from zoneinfo import ZoneInfo from agent_env.env.gateway.constants import EXT_NOTIFICATIONS_URI -DEFAULT_TIME_ZONE = "UTC" _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_zone(name: Optional[str]) -> ZoneInfo: - """The IANA zone delivered lines show their times in; UTC when none is named.""" - try: - return ZoneInfo(name or DEFAULT_TIME_ZONE) - except Exception as e: # ZoneInfoNotFoundError, or a ValueError for a malformed key - raise ValueError(f"time_zone must be an IANA name such as 'America/Los_Angeles', got {name!r}") from e - - -def label_time(at: Optional[str], zone: ZoneInfo) -> str: - """'Mon Feb 1, 3:00 PM' in ``zone``; 'now' for an item with no time (queued while the env clock was unset).""" +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" - local = _utc(at).astimezone(zone) - return f"{local.strftime('%a %b')} {local.day}, {local.strftime('%I:%M %p').lstrip('0')}" + at = _utc(at) + return f"{at.strftime('%a %b')} {at.day}, {at.strftime('%I:%M %p').lstrip('0')} UTC" -def delivered_line(item: dict, zone: ZoneInfo) -> str: +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"), zone) + 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], zone: ZoneInfo) -> list[dict]: +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, zone), "metadata": meta}) + 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 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 dab9b211b..145fd5e2d 100644 --- a/src/agent_env/task_step/task_steps/prompt_agent.py +++ b/src/agent_env/task_step/task_steps/prompt_agent.py @@ -39,7 +39,7 @@ 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, label_zone, next_message, take_due +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, @@ -177,7 +177,6 @@ def __init__( snapshot_config: Optional[SnapshotConfig] = None, live_trajectory: bool = False, post_turn_hooks: Optional[list[dict]] = None, - time_zone: Optional[str] = None, ): # `prompt` is the legacy text-only field; `parts` is the multimodal # form that's the authoritative A2A wire shape. Exactly one must be set: @@ -193,7 +192,6 @@ def __init__( raise ValueError("max_conversation_turns must be >= 1") if post_turn_hooks is not None: validate_hook_specs(post_turn_hooks) - label_zone(time_zone) # the zone delivered notifications are labelled in; refused here if it isn't one if model_params: reserved = set(model_params) & MODEL_PARAMS_RESERVED if reserved: @@ -225,7 +223,6 @@ def __init__( self.snapshot_config = snapshot_config self.live_trajectory = live_trajectory self.post_turn_hooks = post_turn_hooks - self.time_zone = time_zone @staticmethod def _validate_parts(parts: list[dict]) -> None: @@ -303,8 +300,6 @@ def to_dict(self) -> dict: base["live_trajectory"] = True if self.post_turn_hooks is not None: base["post_turn_hooks"] = self.post_turn_hooks - if self.time_zone is not None: - base["time_zone"] = self.time_zone return base @classmethod @@ -342,7 +337,6 @@ def from_dict(cls, data: dict) -> PromptAgentTaskStep: ), live_trajectory=data.get("live_trajectory", False), post_turn_hooks=data.get("post_turn_hooks"), - time_zone=data.get("time_zone"), ) def _trajectory_prefix(self) -> str: @@ -557,7 +551,6 @@ async def _converse( # 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 - zone = label_zone(self.time_zone) 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. @@ -689,7 +682,7 @@ def record_turn() -> None: # 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, zone), answer["message"]) + 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) diff --git a/tst/integration/task_step/test_prompt_agent_notifications_local.py b/tst/integration/task_step/test_prompt_agent_notifications_local.py index 885340d48..324c78b0a 100644 --- a/tst/integration/task_step/test_prompt_agent_notifications_local.py +++ b/tst/integration/task_step/test_prompt_agent_notifications_local.py @@ -62,7 +62,7 @@ async def test_what_came_due_for_the_solver_opens_its_next_message_on_a_real_gat 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, time_zone="America/Los_Angeles", + poll_interval_seconds=1, post_turn_hooks=[{"file_artifact_id": hook.id, "file_artifact_version": hook.version, "params": {"end_time": "P5D"}}], ).execute(context) @@ -75,8 +75,8 @@ async def test_what_came_due_for_the_solver_opens_its_next_message_on_a_real_gat 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, 7:40 AM · your scheduled check] look for new rugs", - "[Tue Mar 3, 8:40 AM · Curbfinch alert] New listing: 2BR on Ivy St", + ["[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]] == [ diff --git a/tst/unit/task_step/test_prompt_agent_notifications.py b/tst/unit/task_step/test_prompt_agent_notifications.py index 169185e7d..b3aa579e1 100644 --- a/tst/unit/task_step/test_prompt_agent_notifications.py +++ b/tst/unit/task_step/test_prompt_agent_notifications.py @@ -14,7 +14,7 @@ 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, label_zone, next_message +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 @@ -88,14 +88,14 @@ async def test_what_came_due_opens_the_next_message_ahead_of_the_user_sims_reply _alder_and_ivy_due(peers) ctx = _ctx() - await _step(time_zone="America/Los_Angeles").execute(ctx) + await _step().execute(ctx) assert _to_agent(ctx)[1] == [ - {"kind": "text", "text": f"[Mon Feb 1, 3:00 PM · your scheduled check] {ALDER}", + {"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, 3:10 PM · Curbfinch alert] {IVY}", + {"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"}}}, @@ -140,15 +140,15 @@ async def test_the_last_turn_takes_nothing(monkeypatch): @pytest.mark.asyncio -async def test_labels_are_in_utc_unless_the_step_names_a_time_zone(monkeypatch): +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 · your scheduled check] {ALDER}", - f"[Mon Feb 1, 11:10 PM · Curbfinch alert] {IVY}", "ok"] + 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 @@ -161,7 +161,7 @@ async def test_only_what_is_due_for_the_agents_own_role_reaches_it(monkeypatch): 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 · Calendar] for the planner", "ok"] + 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"] @@ -196,7 +196,7 @@ async def test_a_hook_that_says_nothing_sends_only_what_came_due_and_the_run_end assert [[p["text"] for p in parts] for parts in _to_agent(ctx)] == [ ["find me a 2BR"], - [f"[Mon Feb 1, 11:00 PM · your scheduled check] {ALDER}", f"[Mon Feb 1, 11:10 PM · Curbfinch alert] {IVY}"], + [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"]) @@ -211,7 +211,7 @@ async def test_what_came_due_goes_ahead_of_a_hooks_message(monkeypatch): 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 · Messages] Jamie: did the rug thing get sorted?", "[Tue] the person writes"] + "[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: @@ -220,12 +220,9 @@ def _hook_spec(hook_id: str) -> dict: # ---- the step field and the message builder ------------------------------------------------------------------------- -def test_time_zone_round_trips_is_stored_only_when_set_and_is_checked_when_built(): - assert "time_zone" not in _step().to_dict() - stored = _step(time_zone="America/Los_Angeles").to_dict() - assert PromptAgentTaskStep.from_dict(stored).time_zone == "America/Los_Angeles" - with pytest.raises(ValueError, match="IANA name"): - _step(time_zone="Pacific Time") +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: @@ -235,20 +232,20 @@ def _item(source: str, channel: str, text: str, at: str | None = MONDAY, times: 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, label_zone(None)) == ( - "[Mon Feb 1, 3:00 PM · your scheduled check] check rugs (it came due 4 times while you were away)") + 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, label_zone(None)) == "[now · Gmail] hi" + assert delivered_line(queued_unclocked) == "[now · Gmail] hi" unknown_source = _item("calendar", "Calendar", "Standup in 10 minutes") - assert delivered_line(unknown_source, label_zone(None)) == "[Mon Feb 1, 3:00 PM · 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], label_zone(None)) == [ - {"kind": "text", "text": "[Mon Feb 1, 3:00 PM · Gmail] New email", "metadata": meta}, + 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}, ]