Repository navigation
feat(gateway): notifications — checks a role schedules for itself, notify, and prompt_agent delivers them - #351
Conversation
…tify_agent
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 <noreply@anthropic.com>
…on times, wait for discovery - 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 <noreply@anthropic.com>
… 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 <noreply@anthropic.com>
…rahchitty/agent-wakeups # Conflicts: # src/agent_env/task/episode/rules.py
…history is bounded - 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 <noreply@anthropic.com>
…rahchitty/agent-wakeups # Conflicts: # src/agent_env/env/gateway/constants.py
|
Proposal: let A sentence isn't always enough for the agent to pick up where it left off. It may want to see the floor plan it was asking about again, or keep the exact ids of the listings it has already seen. Could
Rules I'd start with:
"note": {
"description": "What to do when the check comes due: text, or a list of parts.",
"anyOf": [
{"type": "string"},
{"type": "array", "minItems": 1, "maxItems": 8, "items": {"anyOf": [
{"type": "object", "required": ["kind", "text"], "additionalProperties": false,
"properties": {"kind": {"const": "text"}, "text": {"type": "string"}}},
{"type": "object", "required": ["kind", "file"], "additionalProperties": false,
"properties": {"kind": {"const": "file"}, "file": {"type": "object", "required": ["uri"],
"additionalProperties": false, "properties": {"uri": {"type": "string"},
"mimeType": {"type": "string"}, "name": {"type": "string"}}}}},
{"type": "object", "required": ["kind", "data"], "additionalProperties": false,
"properties": {"kind": {"const": "data"}, "data": {"type": "object"}}}]}}]}
Whatever builds the wake message (the #352 hook today) would return parts instead of one joined string. Post-turn hooks have accepted parts since #265.
Things to decide while implementing:
|
|
Proposal: rename the extension to "Wakeups" names the effect on the agent rather than the thing itself. What the extension holds is notifications: checks a role schedules for itself, and messages triggers send. Neither this PR nor #352 has merged, so the rename can stay in v1.
The agent's tools keep their names ( Notes on a few of these:
|
|
Proposal: The result currently echoes the note the agent just wrote. That's up to 2,000 characters in the agent's context with nothing new in it, and with multipart notes it would repeat file URLs and data too. The trajectory already logs the call's arguments. I'd return only what the gateway decided: the id and the normalised times. Today: {"check_id": "check-2", "note": "Check the saved search …", "next": "2027-02-02T17:00:00Z", "every": "P1D", "until": "2027-02-06T17:00:00Z"}Proposed: {"check_id": "check-2", "next": "2027-02-02T17:00:00Z", "every": "P1D", "until": "2027-02-06T17:00:00Z"}
{"checks": [{"check_id": "check-1", "note": "See if Dana replied …", "attached": ["file: alder-plan.png"], "next": "2027-02-01T23:00:00Z"}]}
|
|
Proposal: have Today a scheduled check reaches the agent only if the task's Proposal:
The next message when two things came due and the user sim also replied: [{"kind": "text", "text": "[Mon Feb 1, 3:00 PM · your scheduled check] See if Dana replied about the 2BR on Alder St …"},
{"kind": "text", "text": "[Mon Feb 1, 3:10 PM · Curbfinch alert] New listing: 2BR on Ivy St, $3,050/mo"},
{"kind": "text", "text": "Actually, can we look at places in Fremont too?"}]Two small additions with it:
This is probably its own PR, landing between this one and #352. |
|
Proposal: give each notification a recipient, with checks off unless a task names roles Any role that lists the gateway's tools sees
This is much cheaper to add before merge than as a v1 addition afterwards. |
|
re: multipart notes thanks!! I think sticking to text is more reflective of the real products, since from their docs a scheduled follow-up is basically just a saved text instruction + whatever they already have (ChatGPT tasks re-run a saved prompt, Spark runs the instructions + a skill, Dots/Duet lean on the convo + memory), and none attach files or data to the reminder itself. our agent wakes up in the same convo so it can already look back, and ids fit fine in the text. happy to revisit later if we see agents needing it |
…fications/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 <noreply@anthropic.com>
…e 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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
…start of the agent's next message 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] <note>" (with "(it came due N times while you were away)") and "[Mon Feb 1, 3:10 PM · Curbfinch alert] <text>". 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 <noreply@anthropic.com>
|
thanks for these! pushed to this PR:
a few calls where the spec was silent: |
… 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 <noreply@anthropic.com>
|
Proposal: one shape for every item Delivered, a scheduled check and a trigger notification are the same thing: a labelled message to a recipient at a time. Today they're two shapes told apart by One shape, with where it came from as data: {"id": "check-1", "to": "default", "at": "2027-02-01T23:00:00Z", "times": 1,
"source": "scheduled_check", "source_id": "check-1",
"channel": "your scheduled check", "parts": [ … ]}
{"id": "note-3", "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, $3,050/mo"}]}
This replaces the item |
|
Proposal: tighten the The descriptions leave out rules the agent only learns from errors: the minimum repeat, the 10-check limit, that seconds are required, that months and years aren't accepted, and that replies come back in UTC. "ISO 8601 duration" also promises more than the parser accepts ( And at the end of the tool description: "You can have at most {max_active} checks at once. Times you get back are in UTC." Two related fixes:
|
|
Proposal: document how
|
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 <noreply@anthropic.com>
… 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 <noreply@anthropic.com>
…m; 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 <noreply@anthropic.com>
…ps-hook #351 renamed wakeups/v1 to notifications/v1; hooks may now invoke EXT_NOTIFICATIONS_URI in place of EXT_WAKEUPS_URI. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s (notifications/v1)
The hook no longer takes or labels what came due: after moving the frozen clock to the next thing due for the
agent's role (a world event, notifications/v1 next_due_at, or a person message) it returns {"message": []}, and
prompt_agent takes and delivers. A person's message is still the hook's own reply, after the delivered lines.
- names from #351: notifications/v1, configure_scheduled_checks with roles, the notify action, next_due_at
- new `role` param (default "default"): the recipient whose state it reads, since a hook can't see the agent's role
- `time_zone` stays only for the person-message timestamps; delivered lines use prompt_agent's time_zone
- records name each due item's `source` (scheduled_check, trigger) plus "person"
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rahchitty/agent-wakeups
…fication
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 <noreply@anthropic.com>
|
Proposal: drop
Whatever the zone, the label should name it. Today it prints The #352 hook's lines for the person's messages would then use UTC too, so every delivered line reads the same way. |
…in UTC and say so 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 <noreply@anthropic.com>
…s in UTC; no time_zone param Follows #351 dropping time_zone from prompt_agent: every delivered line, the person's included, is labelled in UTC and says so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What and why
In a multi-day task the agent needs a realistic way to come back later. Today it only hears from the world when the user sim writes, or when a hook wakes it for every world event. Real assistants don't work like that: they set their own reminders, and they get pushed only through real channels (a saved-search alert, a text).
This adds that, opt-in per task:
urn:agentenv:notifications/v1(env/gateway/notifications.py): notifications on the env's virtual clock, each for one recipient role (to). The card says it's unrelated to A2A push notifications. Two kinds:schedule_check(note, at | every, until),list_scheduled_checksandcancel_scheduled_check. A check is for the role that scheduled it, and that role lists and cancels only its own.schedule_checkreturns the id and the times, not the note back. Caps: at most 10 active checks per role, and repeats no shorter than 1 hour; both are configurable.notifytrigger action: a designer-declared notification on a named channel ({"type": "notify", "channel": "Curbfinch alert", "text": "..."}), for the solver's role unless it names another withto. It's queued for the agent, not sent as the user. Only the text length is logged.Routes
/notifications/config|state|take|clear.config {"roles": ["default"]}names who gets the tools;[], the default, turns them off and cancels the active checks.take {"to": role}hands out each of that role's due items once.stateexposesnext_due_at, the earliest due check or pending notification, so a runner can jump the clock to it.Only the named roles see the tools. The gateway's existing role filter hides them from every other role, persona executors included, on MCP and on
/step. A check a persona schedules never reaches the solver.configure_scheduled_checkstask step: names the roles on one env (rolesis required, e.g.["default"]). It goes aftersync_env_clockand beforeprompt_agent.prompt_agentdelivers what's due. After each turn the conversation goes on from, it takes what came due for its agent's role on every env that offers the extension, and opens the next message with it, oldest first, ahead of the user side's reply:[{"kind": "text", "text": "[Mon Feb 1, 3:00 PM · your scheduled check] See if Dana replied about the 2BR on Alder St", "metadata": {"agentenv": {"source": "scheduled_check", "check_id": "check-1", ...}}}, {"kind": "text", "text": "[Mon Feb 1, 3:10 PM · Curbfinch alert] New listing: 2BR on Ivy St, $3,050/mo", "metadata": {"agentenv": {"source": "trigger", "id": "notification-2", "trigger_id": "ivy-listed", ...}}}, {"kind": "text", "text": "Actually, can we look at places in Fremont too?"}]The metadata lets a verifier tell delivered lines from the person's words, since the store records the message as one user turn. A
time_zonestep param sets the labels' zone (UTC by default). An answer that ends the conversation takes nothing, so what's due stays queued instate. The user sim is asked as before and never sees the delivered lines. A post-turn hook can return{"message": []}to say nothing, so the agent gets only what came due; if nothing has, the conversation ends (nothing_to_send). The message builder istask_step/task_steps/notification_delivery.py, for hooks to reuse.register_env_triggersrefusesnotifyon a gateway that lacks the feature.Nothing changes for tasks that don't use it. No role has the tools until a task names one. With nothing due, every message is what it was before; the only addition is one
takecall per turn on a gateway that offers the extension, and older gateways don't./notifications/configreturns 409 if a backing server already serves a tool with one of these names.This takes four of @earakely-scale's review proposals: the rename to notifications, no echo, recipients, and
prompt_agentdelivery. Notes stay text only. The design and the decisions behind it are in Linear FD-4433. #352 follows with the new names,rolesandto; its hook keeps moving the clock to the next due time and leaves delivery toprompt_agent.How it was tested
PYTHONPATH=src:packages/agentenv-protocol/src python -m pytest tst/unit/env/gateway/notifications_test.py tst/unit/task_step/test_configure_scheduled_checks.py tst/unit/task_step/test_prompt_agent_notifications.py tst/unit/task_step/test_register_env_triggers.py tst/unit/env/gateway/gateway_card_test.py tst/unit/task_step/test_prompt_agent_post_turn_hooks.py: 209 passed. They cover the rename, no echo, recipients (a persona role neither sees nor calls the tools, and its checks don't reach the solver),next_due_atwith pending notifications, and delivery: with a user-sim reply, nothing due, an ending answer, the last turn, the agent's own role, the metadata, the time zone, a hook's message, a hook that says nothing, and a take that keeps failing.-m int_test_slow):tst/integration/task_step/test_prompt_agent_notifications_local.pyruns the next-env-event hook over simulated days. The agent's own check and anotifyopen its next message ahead of the hook's nudge, labelled in Los Angeles time, and a role the config doesn't name gets a 403 fromschedule_check. 1 passed. The existingtest_next_env_event_hook_local.py(multi-day case) still passes with the extension on the card; its two takes found nothing, and every message was as before.schedule_checksensibly. They scheduled every 3–24 h, never ran away, and never mistook a labelled notification for the person.@earakely-scale
🤖 Generated with Claude Code
The dependent hook in PR #352 still needs its callers updated before these changes are safe to merge together.
Fix with agent prompt
Summary
Adds role-specific scheduled checks, trigger notifications, and delivery before the user’s next reply.
Reviews (13) · Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." · Reviewed by Greptile