Skip to content

feat(gateway): notifications — checks a role schedules for itself, notify, and prompt_agent delivers them - #351

Open
sarah-chitty-1 wants to merge 16 commits into
mainfrom
sarahchitty/agent-wakeups
Open

sarah-chitty-1 wants to merge 16 commits into
mainfrom
sarahchitty/agent-wakeups

Conversation

@sarah-chitty-1

@sarah-chitty-1 sarah-chitty-1 commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator

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:

    • Scheduled checks. The roles a task names get three gateway tools: schedule_check(note, at | every, until), list_scheduled_checks and cancel_scheduled_check. A check is for the role that scheduled it, and that role lists and cancels only its own. schedule_check returns 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.
    • notify trigger action: a designer-declared notification on a named channel ({"type": "notify", "channel": "Curbfinch alert", "text": "..."}), for the solver's role unless it names another with to. 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. state exposes next_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_checks task step: names the roles on one env (roles is required, e.g. ["default"]). It goes after sync_env_clock and before prompt_agent.

  • prompt_agent delivers 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_zone step param sets the labels' zone (UTC by default). An answer that ends the conversation takes nothing, so what's due stays queued in state. 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 is task_step/task_steps/notification_delivery.py, for hooks to reuse.

  • register_env_triggers refuses notify on 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 take call per turn on a gateway that offers the extension, and older gateways don't. /notifications/config returns 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_agent delivery. Notes stay text only. The design and the decisions behind it are in Linear FD-4433. #352 follows with the new names, roles and to; its hook keeps moving the clock to the next due time and leaves delivery to prompt_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_at with 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.
  • Full unit suite compared with clean main in the same venv: no new failures. The pre-existing ones are the optional extras (openenv, drivers, mcp_cli_builder) and the shipped-bundle lists.
  • On a real local gateway (-m int_test_slow): tst/integration/task_step/test_prompt_agent_notifications_local.py runs the next-env-event hook over simulated days. The agent's own check and a notify open 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 from schedule_check. 1 passed. The existing test_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.
  • Before building, a model probe (18 runs across Opus, GPT and Gemini) checked that models use schedule_check sensibly. They scheduled every 3–24 h, never ran away, and never mistook a labelled notification for the person.

@earakely-scale

🤖 Generated with Claude Code

RetriggerView in GreptileConfidence Score: 5/5

The reviewed changes appear safe to merge.

Summary

Adds role-specific scheduled checks, trigger notifications, and delivery through prompt_agent.

  • Named roles can schedule and manage their own checks on an environment.
  • Triggers can queue a channel message for a named role.
  • Due notifications appear before the reply in the agent's next message.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[Role schedules a check] --> Q[Gateway notifications]
  B[Trigger sends notify] --> Q
  Q --> C[Take due items for the agent's role]
  C --> D[Label notification text]
  D --> E[Next message before the user reply]
Loading

Reviews (11) · Last reviewed commit: "fix(triggers): a notify whose text resol..." · Reviewed by Greptile

…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>
Comment thread src/agent_env/env/gateway/gateway.py Outdated
Comment thread src/agent_env/env/gateway/wakeups.py Outdated
Comment thread src/agent_env/env/gateway/wakeups.py Outdated
Comment thread src/agent_env/env/gateway/wakeups.py Outdated
Comment thread src/agent_env/task_step/task_steps/configure_wakeups.py Outdated
sarah-chitty-1 and others added 3 commits October 27, 2026 10:07
…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
Comment thread src/agent_env/env/gateway/wakeups.py Outdated
Comment thread src/agent_env/env/gateway/notifications.py Outdated
sarah-chitty-1 and others added 2 commits October 27, 2026 15:02
…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
@earakely-scale

Copy link
Copy Markdown
Collaborator

Proposal: let note be multipart

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 note accept either a string, as today, or a list of A2A parts?

  • {"kind": "text", "text": …}: what to do. Every note needs at least one.
  • {"kind": "file", "file": {"uri": …, "mimeType": …, "name": …}}: something to look at again, by http(s) URL only. No inline bytes: a model shouldn't base64 a file into a tool call, and the gateway shouldn't hold it in memory.
  • {"kind": "data", "data": {…}}: state to keep exactly, as a JSON object.

Rules I'd start with:

  • A plain string still works and becomes one text part.
  • 1 to 8 parts, at least one of them text.
  • 2,000 characters of text across all text parts (today's limit).
  • A file URI up to 2,048 characters; mimeType and name optional.
  • Data up to 4,000 characters once serialized.

note in _SCHEDULE_SCHEMA:

"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"}}}]}}]}

Wakeups.schedule would turn note into a list of parts and check these rules by hand, since the gateway doesn't depend on a2a-sdk. post_turn_hooks._parts already validates every part with Part.model_validate on delivery. The handler takes note: str | list[dict]; its arg model needs no change, since every field is Any.

/wakeups/state and /wakeups/take would keep note as text (the text parts joined by a blank line) and add parts, so anything reading note as a string keeps working.

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.

  • File parts pass through as FileParts. The Claude Code and Gemini agents download them and add the paths at the end of the prompt, so the labelled line should name each attachment, e.g. [Mon Feb 1, 3:00 PM · your scheduled check] … (attached: alder-plan.png).
  • Data parts go out as a JSON text block, because the Claude Code and Gemini agents drop DataParts today (Pi already renders them as JSON).

Things to decide while implementing:

  1. A file the agent can't download fails its turn: Claude Code ends it with filepart_error. A presigned URL can expire before a daily check comes due. Should the builder check each URL first and fall back to a text line naming the file, or accept the failure?
  2. A presigned URL's query string is a credential, and /wakeups/state, list_scheduled_checks and the trajectory would show it in full. Refuse URIs with a query string, or redact them?
  3. It's worth rerunning the schedule_check model probe with the anyOf schema, since some providers handle anyOf in tool schemas less well than a plain string.

@earakely-scale

Copy link
Copy Markdown
Collaborator

Proposal: rename the extension to urn:agentenv:notifications/v1

"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.

Today Renamed
urn:agentenv:wakeups/v1, EXT_WAKEUPS_URI urn:agentenv:notifications/v1, EXT_NOTIFICATIONS_URI
/wakeups/config|state|take|clear /notifications/config|state|take|clear
env/gateway/wakeups.py, class Wakeups notifications.py, class Notifications
configure_wakeups, metadata.wakeup_configurations configure_scheduled_checks, metadata.scheduled_check_configurations
trigger action notify_agent, TRIGGER_NOTIFY_AGENT notify, TRIGGER_NOTIFY
item kind notification trigger
next_check_at next_due_at

The agent's tools keep their names (schedule_check, list_scheduled_checks, cancel_scheduled_check): a scheduled check is a notification the agent schedules for itself.

Notes on a few of these:

  • The card description should say the extension is unrelated to A2A push notifications. The env card mirrors A2A's capabilities, where pushNotifications sits right next to extensions.
  • configure_scheduled_checks, because the step only configures checks. Trigger notifications need no config.
  • Item kind trigger, because once every item is a notification, a kind called "notification" doesn't tell them apart.
  • next_due_at should be the earliest of the active checks' next and the pending notifications' at. Today next_check_at ignores pending notifications, so something waiting on it can sit on a queued one.
  • Optional: the feat(examples): agent-wakeups hook — wake the agent only for its own checks, notifications and the person #352 example could follow, e.g. advance-to-next-event, since its job is moving time forward.

@earakely-scale

Copy link
Copy Markdown
Collaborator

Proposal: schedule_check shouldn't repeat the note back

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"}

list_scheduled_checks still needs the note, because it's how the agent tells its checks apart. It would show the note as text only and name any other parts:

{"checks": [{"check_id": "check-1", "note": "See if Dana replied …", "attached": ["file: alder-plan.png"], "next": "2027-02-01T23:00:00Z"}]}

/wakeups/state and /wakeups/take keep the full note and its parts: they're harness-facing, and take is the delivery.

@earakely-scale

Copy link
Copy Markdown
Collaborator

Proposal: have prompt_agent deliver what's due

Today a scheduled check reaches the agent only if the task's prompt_agent step lists the agent-wakeups hook from #352. A task that runs configure_wakeups without it gives the agent checks that never arrive, and nothing warns about it. The tool description promises "when it comes due you are woken with your note", so delivery should happen whenever the extension is in use, not only when a particular hook is wired up.

Proposal:

  • After every turn, prompt_agent calls take on every deployed env whose card has the extension, whether or not scheduled checks are on, since trigger notifications need no config.
  • Anything due goes at the start of the next message, oldest first, ahead of the user side's reply, in one message. The reply keeps its current, unlabelled format, so user-sim tasks see no change when nothing is due.
  • If the answer ends the conversation, prompt_agent doesn't call take. The items stay queued and visible in /wakeups/state.
  • The feat(examples): agent-wakeups hook — wake the agent only for its own checks, notifications and the person #352 hook keeps the part that's a task's own choice: moving the clock to the next due time when nothing else would happen. It and prompt_agent share one message builder. take hands each item out once, so both calling it is safe.

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:

  • A time_zone step parameter for the labels (default UTC), like the hook's time_zone param today.
  • metadata on each delivered part, e.g. {"agentenv": {"source": "scheduled_check", "check_id": "check-1"}}. The conversation store records the combined message as one user turn, so without it verifiers can't tell what the person said from what was delivered. The user sim isn't affected, since it only receives the agent's replies.

This is probably its own PR, landing between this one and #352.

@earakely-scale

Copy link
Copy Markdown
Collaborator

Proposal: give each notification a recipient, with checks off unless a task names roles

Any role that lists the gateway's tools sees schedule_check, persona executors included, and there's one shared queue: a check a persona schedules would reach the solver, because take returns everything to whoever calls it.

  • config takes roles: which roles get the tools. It defaults to [], so no role has them unless a task opts in, and it replaces the scheduled_checks boolean: an empty list means checks are off, with the tools removed and active checks cancelled. Hiding the tools from other roles can reuse the existing role filter.
  • configure_wakeups requires a non-empty roles, so a task always names who gets the tools.
  • schedule_check records the calling role as the check's recipient, to.
  • notify_agent takes an optional to (default "default", the solver's role). A trigger author has already opted in, and receiving a notification needs no tools.
  • take and state take to and return only that recipient's items. prompt_agent passes the role its target agent uses.
POST /wakeups/config {"roles": ["default"]}  → these roles get the tools
POST /wakeups/config {"roles": []}           → off (the default)
schedule_check(...) by role X                → the check records "to": "X"
{"type": "notify_agent", "to": "default", "channel": …, "text": …}
POST /wakeups/take {"to": "default"}         → only that recipient's items

This is much cheaper to add before merge than as a v1 addition afterwards.

@sarah-chitty-1

Copy link
Copy Markdown
Collaborator Author

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

sarah-chitty-1 and others added 4 commits October 29, 2026 10:36
…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>
@sarah-chitty-1 sarah-chitty-1 changed the title feat(gateway): wakeups — checks an agent schedules for itself, and notify_agent feat(gateway): notifications — checks a role schedules for itself, notify, and prompt_agent delivers them Oct 29, 2026
@sarah-chitty-1

Copy link
Copy Markdown
Collaborator Author

thanks for these! pushed to this PR:

  • rename to urn:agentenv:notifications/v1 per your table (notify, configure_scheduled_checks, kind trigger, next_due_at now counts pending notifications too) — 9007d27
  • schedule_check returns only the id + times — 5f847fa
  • recipients: config takes roles (default [] = off), checks are for the role that scheduled them, notify takes to, take/state take to — 01f81d4
  • prompt_agent delivers what's due at the start of the next message, one labelled part each with metadata.agentenv, plus a time_zone param — 5cef82a. went in this PR rather than a separate one

a few calls where the spec was silent: take with no to = "default"; max_active is per role; unknown config keys and roles: ["*"] are refused; a hook can return {"message": []} to say nothing (agent then gets only what's due, or the convo ends with nothing_to_send if nothing is) — small loosening of the #265 hook contract. #352 will follow with the hook only moving the clock.

Comment thread src/agent_env/env/gateway/constants.py
Comment thread src/agent_env/task_step/task_steps/notification_delivery.py Outdated
Comment thread src/agent_env/task_step/task_steps/notification_delivery.py Outdated
… 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>
@earakely-scale

Copy link
Copy Markdown
Collaborator

Proposal: one shape for every item take returns

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 kind, and consumers have to branch on it. #352's _message treats anything that isn't a check as a notification, so a new kind would fail on item['channel'].

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"}]}
  • source/source_id say where an item came from, for code and verifiers. channel is the label the agent sees. parts is the content: a check's note, or a trigger's text as one text part.
  • The message builder becomes [when · {channel}] plus the parts, with no branching, so a source added later is delivered with no consumer change.
  • Checks get the fixed channel your scheduled check, and validate_channel rejects that label for notify_agent, so a trigger can't pass itself off as the agent's own reminder.
  • Only delivered items change. state keeps checks and pending_notifications separate, since their bookkeeping differs.

This replaces the item kind row in the rename comment above.

@earakely-scale

Copy link
Copy Markdown
Collaborator

Proposal: tighten the schedule_check descriptions and time errors

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 (P1M, P1Y and PT1.5H are all refused).

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)."

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:

  • A bad time's error names a field the agent never sees, because the parser is shared with the clock: at: invalid RFC3339 virtual_time 'pt6h': …. _when could give its own message, and treat a leading lowercase p as a duration so pt6h gets a duration error.
  • {min_interval} and {max_active} come from the config, but _register_wakeup_tools uses setdefault, so reconfiguring keeps the first descriptions. Replace the tools on each config instead.

@earakely-scale

Copy link
Copy Markdown
Collaborator

Proposal: document how config applies settings, and have clear reset them

  • config replaces every setting: a field left out goes back to its default. With roles defaulting to [] (see the recipients comment), {"max_active": 5} on its own would turn checks off. That's fine as long as the card description says "replaces all settings"; the step always sends every field.
  • A new config leaves existing checks alone unless it turns checks off: lowering max_active leaves checks above the new limit active, and raising min_interval leaves existing repeats as they are. Worth a line in the description too.
  • The 409 for a backing server that already serves one of the tool names also blocks turning checks off. Turning off registers nothing, so it can skip that check.
  • clear drops checks and notifications but leaves max_active and min_interval at their last values, so state afterwards shows old limits. It should reset them, and roles, to the defaults.

sarah-chitty-1 and others added 2 commits October 29, 2026 11:48
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>
sarah-chitty-1 added a commit that referenced this pull request Oct 29, 2026
…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>
sarah-chitty-1 added a commit that referenced this pull request Oct 29, 2026
…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>
Comment thread src/agent_env/env/gateway/triggers.py Outdated
…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>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants