Repository navigation
feat(examples): agent-wakeups hook — wake the agent only for its own checks, notifications and the person - #352
Merged
Conversation
…to the next on simulated time prompt_agent's opt-in between_turns: next_env_event makes a multi-turn run event-driven, for long-running and background-agent evals whose events are hours or days apart. After each agent turn it reads the envs' trigger state, brings the armed env clocks up to the earliest pending time-trigger mark, waits for the triggers due there to fire, fail or be cancelled (with a timeout), and wakes the agent with a neutral message or the user simulator's agent-trigger decide step. It stops when no time trigger is pending, at end_time, or at max_conversation_turns. The user side is never asked. The gateway clock gains PUT /clock/advance (clock/v1 "advance"): a forward jump on the current arm that keeps t0, rate and generation, so relative and recurring time-trigger marks keep their meaning and delays count the jumped time. set_time is a re-arm that re-anchors them; against a gateway without advance, prompt_agent re-arms at the env's own rate and refuses a run whose pending time triggers are relative or recurring. With between_turns set, or record_env_clock, every conversation message prompt_agent records carries env_clock, each env's virtual time when it was sent or received, so a grader can place the agent's messages on the same axis as the trigger firing log and the gateway trajectory. Both options are off by default; unset, prompt_agent, the conversation store and the clock behave as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two envs can each have a trigger with the same id due at one mark; keyed by id
alone, the second was skipped and the wait ran to its timeout. Outcomes are now
keyed by (env id, trigger id) and recorded as {env_id: {trigger_id: outcome}}.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…dvance lands is not an error With a running clock the mark can pass between the clock read and the advance, which the gateway then refuses as backwards. On that 400 the clock is read again, and if it has reached the mark the move is recorded as "passed"; any other refusal still fails the step. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…next mark in the schedule A recurring trigger whose fire outlived the wait (an action delay on a frozen clock, say) stays firing with its next mark already stamped; only armed ones were scheduled, so the run could end with "no_pending_time_triggers" despite future marks. Firing and queued triggers with a next mark are now scheduled too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… with carries the env clock too Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ect with 400, not a server error Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…gain A recurring trigger whose fire outlives its interval on a frozen clock stays firing with the same next mark, so every later turn chose that mark again, waited out the timeout and woke the agent without moving time. Marks that timed out are now skipped, so the run moves on to the next event or end_time. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rring mark, as every other jump does Against a gateway without clock advance, stopping at end_time re-armed the clock unchecked, re-anchoring a pending relative or recurring time trigger past end_time for the steps after it. That jump now runs the same check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… stored message carries the env clock Hooks are Python FileArtifacts defining `async def post_turn(turn, params)`, run in order after every agent turn; the first to return a message or an end decides, else the user side is asked as today, which moved into _UserSide unchanged. test_prompt_agent_user_side.py pins that path and passes unchanged. Agent triggers/v1 decide takes record_unfired, so user_triggers() can ask again in the same turn without resetting once-only triggers. The verifier run context reads env_clock as one EnvClock per armed env. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t_event_at and resumes_at; /step tags a hook's calls PUT /clock/advance runs the trigger time check at once and returns `due`, waking actions that wait out a delay. /triggers/state reports next_event_at, the earliest time the engine acts on its own, and each trigger's resumes_at (triggers/v1 feature next_event_at). /step takes source post_turn_hook with hook_artifact_id (step/v1 feature source). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…om env event to env event The hook waits for what the agent's turn set off, lets a firing agent trigger answer, advances each clock to the earliest next_event_at, waits for it to settle, then asks the agent triggers again or wakes the agent. It ends when nothing is pending or past end_time, and on a gateway without advance. It ships as a bundle with an inbox env and a week-long task; the slow test runs it against the echo agent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both sides add a fourth shipped bundle, mail-mcp and next-env-event: the entry points and the shipped-bundle lists keep both, and the installer journey expects 5 bundles. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…so a change to it fails the test Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e next opening _await_hours reckoned the opening again only when the clock's generation changed, and advance keeps it, so a jump past the close left a stale opening behind and the executor ran outside its hours. It now reckons the opening on every pass, and clock_advanced counts a wait as ended only while its window is open. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…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>
…ps-hook # Conflicts: # src/agent_env/env/gateway/constants.py # tst/unit/bundle/installed_test.py # tst/unit/bundle/shipped_bundles_test.py # tst/unit/test_clean_install_wheel.py
…checks, notifications and the person
A prompt_agent post-turn hook (example bundle agent-wakeups) that runs a conversation over simulated days:
after each turn it jumps the clock, silently, to the next thing that happens — a world event, a check the agent
scheduled (wakeups/v1), or a person's message at a set time — and wakes the agent only when one of its own checks,
a notify_agent notification or a person's message has come due. World events with no channel wake no one, so the
agent isn't told when to look. Each wake is one message, a line per thing with its time and source
("[Tue Mar 3, 6:00 PM · your scheduled check] …", "[Wed Mar 4, 8:00 AM · Messages] …"), so nothing poses as the
person; the hook's record names what each wake was for.
post_turn_hooks: a hook may also invoke wakeups/v1 (state, take).
Stacked on #265 (post-turn hooks, clock advance, next_event_at) and on the wakeups/v1 gateway change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Resolves the bundle-list conflicts in three tests: main added the drivers bundle, this branch next-env-event; keep both. 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>
…ler expects six bundles - /triggers/state shows each trigger's `evaluating`, and the next-env-event hook waits for a state check the agent's turn set off before it moves the clock - test_container_journey: next-env-event makes six shipped bundles Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts: # tst/unit/bundle/installed_test.py # tst/unit/bundle/shipped_bundles_test.py # tst/unit/test_clean_install_wheel.py
… set off before it moves the clock 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>
…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>
… 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>
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>
Its job is moving time forward to the next thing due for the agent, and wakeups/v1 is now notifications/v1. The bundle, its artifact, the entry point, the shipped-bundle lists and the hook's tests follow; the README says how it differs from next-env-event. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rahchitty/agent-wakeups
…/agent-wakeups-hook Brings in the agentenv-framework version the hub SDK needs (>=0.9.1384), which is what the CodeBuild integration tests failed on. Bundle-list test conflicts resolved by keeping advance-to-next-event alongside main's new computers and desktop bundles. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…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>
A shipped bundle needs a task to run; week.json runs the hook on next-env-event's inbox. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sarah-chitty-1
marked this pull request as ready for review
October 29, 2026 23:21
…triggers don't settle, wakes after its last jump - an env whose clock was never armed is left out instead of crashing the hook on its missing time - triggers still firing at settle_timeout_seconds end the run as triggers_still_firing, so a slow notify isn't lost behind a "nothing can wake the agent" end - what the last allowed jump makes due wakes the agent before max_jumps_per_turn ends the turn Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…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>
…s records; integration test labels in UTC - Turn.records(): this hook's records for this step, so another conversation delivering a person message at the same time no longer makes this one skip it - the local integration test no longer passes the removed time_zone, and expects UTC labels Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
earakely-scale
approved these changes
Oct 30, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What and why
advance-to-next-eventis an example post-turn hook forprompt_agentthat lets one conversation span simulated days.After each agent turn, if nothing is due for the agent, it moves the frozen clock forward to the next thing that is:
schedule_check, feat(gateway): notifications — checks a role schedules for itself, notify, and prompt_agent delivers them #351);notifya trigger sent it (feat(gateway): notifications — checks a role schedules for itself, notify, and prompt_agent delivers them #351);person_messages).Whatever the world does in between happens silently. The agent only finds out by going to look.
The hook only moves time. Since #351,
prompt_agentdelivers what's due itself: it puts each item at the start of the agent's next message, labelled with its time and source:Each turn:
next_event_at), the role'snext_due_at, or the next person message, and repeat.The run ends when nothing else can happen, at
end_time, or aftermax_jumps_per_turn.Params:
end_timerole: the agent's AgentEnv-Role; defaultdefaulttime_zone: for the person messages' timestampsperson_messagessettle_timeout_secondsmax_jumps_per_turnIt ships as the example bundle
examples/advance-to-next-event. Unlikenext-env-event, which nudges the agent at every env event, it lets world events pass silently.The only framework change:
post_turn_hooks._INVOKABLEincludes the notifications extension, so hooks can readstate.How it was tested
tst/unit/task_step/test_advance_to_next_event_hook.py, 11 passed, against an in-process gateway's real clock, triggers and notifications. It covers:notify;end_time;tst/integration/task_step/test_advance_to_next_event_hook_local.py(-m int_test_slow) passed on a real local Docker gateway. Only the agent's own check and a declared notification moved it forward, with exactly the expected lines.tasks/file likenext-env-event's, or to stop shipping as a bundle.schedule_checkcalls, none refused. Every alert and scripted message arrived once, at its time.@earakely-scale
🤖 Generated with Claude Code


Confidence Score: 5/5
The PR appears safe to merge; both numbered findings are fixed and no new blocking issue was found.Summary
Adds
advance-to-next-event, an example hook that moves a frozen clock past silent world events and wakes the agent for its checks, notifications, or scripted person messages.Diagram
%%{init: {'theme': 'neutral'}}%% flowchart TD A[Agent finishes a turn] --> B[Wait for triggers to finish] B --> C{Agent trigger answers?} C -->|Yes| D[Return its answer] C -->|No| E{Anything due for the agent or person?} E -->|Yes| F[Return message] F --> G[prompt_agent adds due checks and notifications] E -->|No| H{Another event within limits?} H -->|Yes| I[Advance clock] I --> B H -->|No| J[End conversation]Reviews (4) · Last reviewed commit: "fix(examples): advance-to-next-event rea..." · Reviewed by Greptile