Repository navigation
feat(always_on): a step that runs an assistant over days of task time, with a simulated user (FD-5364) - #566
sarah-chitty-1 wants to merge 55 commits into
Conversation
… s (FD-5333) An executor holds message/send open until its task ends, so the 30 s read timeout failed every fire whose contact took longer, even though the reply usually still landed. The send now reads for the executor's timeout_seconds (never less than 30 s); connect and task polls keep 30 s. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The user_chat gateway extension (message_user, opt-in stay_quiet), the user_sim and always_on steps, and the built-in user sim fallback, as they ran in the FD-5332 local rig (detached at a439754). The rig's own triggers.py change is left out; the reviewed FD-5333 fix is merged next. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…on by default (FD-5365) - stay_quiet, nudge and wake_reminder default to on; a task turns each off with false. The run record always carries the settings, and nudges are counted with their outcomes. - The agent is told the rules: appended to the step's system prompt, or, without one, opening the first wake. - An empty reply, or a harness's "(The assistant's reply was empty.)" stand-in, is not closing text, so it gets no nudge. - The regex that flagged closing text "meant for the user" is gone; the nudge replaces it. - Lines the step adds to a wake (rules, reminder, final reminder, nudge) carry metadata.agentenv.source, so a reader can tell them from the user's and the world's. - The user-chat card advertises stay_quiet in config. Tests: gateway user chat (message_user and stay_quiet through a deny-all), and the step's message rules on an in-process gateway, including that neither the nudge nor the reminder reaches the user sim. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- user_sim: no apps setting (the user sim sees only what triggers notify to user_phone; scripted lines are notify to user_says); the version defaults to ours; a time_zone must be a real IANA zone. - The built-in ours works from the description alone: the read-and-answer rules (including "any other date is the computer's clock"), then WHO YOU ARE. versions.ours.system_prompt still replaces it. - Done ends only a one-sitting run. When end_time falls on a later day than the start (in the user's zone) the done is recorded (ends_run false, counts.user_dones) and the run goes on; the user sim is asked again when the agent next messages. - Time zone: the user sim's, else the one sync_env_clock gave the world; refused when the two disagree. It reaches the plug-in and the record. - What comes due in the same minute wakes the agent once: before a wake the clock moves to anything else due within that minute. - The next due time ignores items already due, so a pending one can't hide a later check. - Registration: always_on declares the user-scoped fields it takes, and the episode rules refuse an outside agent for always_on and user_sim (the same hunk as the FD-5371 branch, so the two merge cleanly). The gateway card test counts the user-chat extension. Tests on the in-process gateway: each wake source, jumping in order and never past an event, read times (asleep, busy) and reply delays, one read for messages sent before it, same-minute arrivals, what a read holds and that nothing meant for the agent is in it, done in one-sitting and multi-day runs, and the time zone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- While the task's clock is set, each wake opens with the task's date and time (with the year, and the user's local time when they aren't in UTC), and says any other date the agent sees is the computer's clock. The line is tagged metadata.agentenv.source "time". - Gateway: a role the user-chat config names always has get_time while the clock is set, like message_user, so a task's tool rules (a deny-all, say) can't hide it. Roles the config doesn't name are unchanged. - Without a system prompt, the first wake still opens with the message rules, now ahead of the time line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… multi-day runs (FD-5368) - scheduled_checks (default: on when end_time falls on a later day than the start, in the user's zone) gives the agent's role scheduled checks on each env whose clock is set, at least scheduled_check_min_interval apart (default PT3H, against the gateway's own PT1H default; hourly checks ran a 5-day Opus run out of wakes by Thursday). True or false turns them on or off whatever the run's length. - A role the task already gave checks (configure_scheduled_checks) keeps its own settings; other roles the task named keep theirs. - When checks are on, the agent's rules say it can schedule a wake-up. - Gateway: a role the user-chat config names also always has the check tools its notifications config gives it, so a deny-all can't hide them. - The run record says where checks came from (settings.scheduled_checks). A check's wake counts toward max_wakes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The core-loop ticket lists email among the user_sim settings; it is recorded and handed to the plug-in with the rest of the person. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e always_on uses No behaviour change. UserSimTaskStep and USER_SIMS_KEY move, unchanged, from always_on.py to task_steps/user_sim.py, whose docstring is now the whole interface between the two steps: the settings always_on reads from metadata["user_sims"][name], the plug-in's configure / setup / answer, the person, read, history and ask shapes, and what answer returns. The registry imports the step from its new module; the user-sim-only tests move to test_user_sim.py. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The user sim plug-in gets read["done_ends_run"]: true in a one-sitting run, false in a multi-day one, where done is only recorded. Documented with the read in user_sim.py. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A done with text is held for her reply time and delivered to the agent
like a reply (her prompt says her message reaches the assistant either
way). In a one-sitting run the run ends (user_done) after the agent's
turn on it, with no further reads; a blank done still ends it at once.
A multi-day run records the done and goes on, as before.
- person["opening"] = {"at", "text"}: the opening request and its time,
so a plug-in's setup can put it in her system prompt. It stays the
first line of her first read's you_sent.
- user_sim.py's interface says so, and that a phone item's sender rides
in its channel ("Gmail · Carol Reyes (Mom)"), as in the agent's alerts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One test per item the loop owns on the user sim's checklist: she reads only after message_user (A1); her phone shows only new user_phone items with their time and full text (A4/A5); you_sent holds only her own lines at their times (A8/B3); every message since her last read comes in one read, oldest first (A9); the opening is in person and her first you_sent (A13); one user sim session per run (A14); history is never trimmed (A15); nothing meant for the agent reaches her at any depth (B1/B2/B4); ask sends exactly its text (B8); reply and done arrive after her reply time (C2/C4); used and score are recorded, never sent (C5); and done_ends_run. The harness can now take a plug-in and play the user sim agent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-side fires and executor time limit (FD-5367) state gains, per trigger, running (fires in flight), waits (when each waiting fire goes on) and executor_calls (each call to an executor in progress, with the seconds left of its time limit), and a top-level settings key. Each fire now keeps its own wait, so fires waiting at once don't clobber each other. New triggers/v1 method config, off by default so prompt_agent runs behave as before: - concurrent_fires: a further call that provokes a repeat trigger already firing starts its own fire at once instead of queueing behind the one running. - executor_timeout_seconds: the time limit of an executor whose registration set none (else 120). Calls in one executor conversation (its URL and the trigger's context id) take turns, so fires of one trigger running side by side never resume the same session at once; in sequential mode they never overlap. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…goes only to the object store (FD-5371) A long run's timeline could push what a running job reports back over 2 MB. The summary in metadata["always_on"][step] now holds the counts, end reason, settings and the timeline's object_url, plus real_seconds and timeline_events; when storing fails it keeps the last 20 events instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ts task first runs (FD-5367) A reader of state right after an advance could otherwise see a firing trigger with nothing running and call the world settled before the fire's actions ran. A fire cancelled before it began is uncounted when its task ends. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ck is advanced to its resumes_at Virtual times are microseconds, so resumes_at can fall a hair short of a stochastic delay's true end. On a frozen clock nothing else moves time, so the fire waited forever (and a settle wait spun until its timeout). Under a microsecond left now counts as reached. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ide by side; 300 s contact limit (FD-5367) - The settle wait follows the gateway's executor calls: a contact's reply counts as in progress until it lands or its time limit passes (plus 30 s); other trigger work gets settle_timeout_seconds from the start or the last contact call seen. A gateway that reports no executor calls gets the old fixed wait. - New settings contact_replies_in_parallel (default on) and contact_timeout_seconds (default 300 s, for contacts whose task sets none), applied through triggers/v1 config at the start and recorded under settings.contacts. Old gateways are recorded as not applied. - clock_jump and settle_timeout events say how long the wait took, how many contact calls it saw and which triggers were still at work; the end records contact replies still in progress (pending_at_end.contact_replies_in_progress). - person gains done_ends_run, for setup, which writes her prompt before any read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e, her last read, pending items, finished vs stuck (FD-5369) - end_time always brings a final check-in: the task's final line, else the step's own line (FINAL_DEFAULT_TEXT), which is not the user's and never reaches her you_sent. - The last-turn notice (final_reminder) goes on the run's last wake: at end_time, and on the wake that max_wakes makes the last. Wake events say last. - After the last wake the user sim reads the agent's wrap-up (user_sim_read with last_read); her answer is recorded and a reply is held as pending, never delivered. Not after a one-sitting done, a give-up or an error. - A reply still held at end_time is kept (it was dropped) and recorded. - pending_at_end: held_replies, unread_by_user (with the wake that sent each), user_lines_not_delivered, alerts_never_delivered, scheduled_checks_not_due, contact_replies_in_progress. The summary keeps counts and the first 20 of each; the stored end event has all. - nothing_can_wake_the_agent becomes finished_nothing_pending or stuck, from what is pending. The record and the end event carry end_note, a plain sentence for each end reason. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…D-5370 1-3, FD-5369 4 and 6) - The graded response is every message_user text in order, each with its pretend time; never the agent's closing text (it was the last wake's messages plus its closing text). - The conversation graders read (a2a_conversations[step]) is now the user's chat: her messages (opening, user_says and final lines, replies, done) as user turns, and the agent's message_user texts, with times and the wake that sent each, as one agent message per run of them. The alerts that woke the agent, the step's own lines and its closing text stay in the per-wake prompt parts, trajectories and timeline. - It ends with one last user turn: her replies that never reached the agent, her reaction when her last read said nothing (no reply, or a blank done), and an end marker with the end reason and end_note. - A failed or timed-out agent turn (or nudge turn) sets error_type on the prompt response (the agent's own, else agent_turn_failed / agent_turn_timeout) and error_code, as prompt_agent does, so agent_prompt_response_verifier and rubrics_verifier skip the run; user_sim_error and step_error ends do too. The record carries error_type. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… answers, hook-shaped reply times (FD-5370 4, 6, 7) - App changes: from each env's changelog (urn:agentenv:changes/v1, from where it stood at the start), the step records every change with its actors, by (agent, others, or none: a write the gateway didn't serve, such as a website reached directly), the wake it was seen in and the phase (before_turn, turn, nudge, after_turn, end). The agent's own carry the wake that made them. app_changes timeline events; counts in the record. - reply_to on each reply: the agent's message it answers (wake, time, text) and how it was found: replying_to from the plug-in (a number or a quote), her used quoting it, the only message in the read, or its only question; None when it can't be told. - metadata["post_turn_hooks"][step]: a start record, then one per read in the old psi-wake hook's shape (hook, turn, read_at, wrote_at, outcome; user_reads_at = when her reply reached the agent; reply_to; delivered_in_wake). ML's asked_before_spending check (Surprise 30th process_checks.py) now grades a step run - checked offline: a booking before her reply fails, one after passes, one with no reply fails - instead of skipping it as 'no psi-wake hook records'. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the run context, not every wake's trajectory (FD-5370, FD-5371) With 40-60 wakes, every wake's trajectory may not fit the judge and costs a lot. Only for a prompt an always_on step made (metadata["always_on"][its step]), the verifier grades from a copy of itself that sends no trajectories, shows the run context (conversation and trigger log, unless the verifier names its own parts) and adds the run wake by wake (always_on.render_for_judge): each wake's time, what woke the agent, how much it sent the user, its tool calls and the app changes made around it, then how the run ended and what was pending. grade_up_to_turn and screenshot-citing formats are refused for such a prompt. Every other prompt is graded exactly as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…8-hour job (FD-5371) The hub runs a step as one 8-hour job and retries one that runs over, against a world whose clock has already moved. New setting time_budget_seconds: once the time spent plus one wake's worst case (wake_reserve_seconds: the agent's turn and nudge turn, two settle waits, a read) reaches it, the agent gets its closing wake (what is due, the step's wrap-up line, the last-turn notice), the user sim reads the wrap-up, and the run ends with end reason time_budget (a time_budget_reached event records when). The default is 7 h, lowered so the budget plus one wake's worst case fits the 8-hour job with 10 minutes spare: 6.98 h at the default 600 s turn timeout, 6.32 h at 1800 s (Surprise, Tree), 5.32 h at 3600 s (Holiday). A budget a task sets that can't fit, and turn timeouts that leave no room for any run, are refused when the step is built. The record's settings carry the budget and the reserve. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s its number and its own executor calls - concurrent_fires now runs a repeat trigger's fires side by side only when every action is a contact's reply (nl). A trigger with tool actions queues as before, since two fires of a read-then-write action could lose a write. - A fire reads its number once, when it starts, and passes it down; overlapping fires used to read trig["attempts"] later and get the other fire's number. - A fire counts as realized by its executor only while its call holds the executor conversation's lock, so a fire waiting for its turn is no longer credited with the calls the executor makes for the other fire. Fires of one trigger taking turns on that lock is intended (two turns never resume one session), now documented. - A fire is counted in `running` only once its task exists, so a schedule that raises leaves no count. - A call queued while only side-by-side fires ran (concurrent_fires turned off meanwhile) is no longer stranded: the last of them to end starts a fire that drains the queue. - An executor reply that completes just past its time limit is kept: message/send reads a little past the limit, a terminal status in its answer is used, and the task is polled once more after the limit. With concurrent_fires off (the default), every trigger behaves as before, bar the late reply now being kept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Declared readOnlyHint=False, each call made the trigger engine re-evaluate every armed state trigger. Nothing depends on that: the only reader of the hint in the gateway is TriggerEngine._is_readonly, the tools change no app's data a state check could read, and action triggers on them still fire (matched before the read-only check). So they are marked read-only, as get_time is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t would be graded _for_always_on refuses settings it can't grade an always_on prompt with (grade_up_to_turn, evidence-citing formats) and ran before the error_type skip, so a run whose agent turn failed raised instead of being skipped. The skip now comes first; every other prompt is verified as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ggers' marks PUT /clock/rate (clock/v1 set_rate) sets virtual_seconds_per_real_second from now on, keeping the virtual time, t0 and generation; 0 freezes the clock. The always_on step needs to freeze the clock outside the agent's turns and run it at the task's rate during them. Re-arming with set_time, as sync_env_clock does, can't be used for that: each re-arm bumps the generation, which re-anchors every relative or recurring time trigger to the new t0, so a trigger at PT2H would be pushed back on every turn and never fire. Additive: no existing route or method changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…izes, never anyone's words
_Run.log() wrote up to 400 characters of each event's fields to logger.info, including the agent's messages,
the user sim's reads and replies, plain replies and closing text. It now logs only fields that name, time or
count things; any other string is logged as its length and a list as its count, as the trigger engine does
("bodies and addresses are PII"). The stored timeline keeps everything.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…g gaps fold their clock jumps A recurring trigger that never wakes the agent (every PT1H, notify to user_phone) made step 3 of the loop jump the clock forever when the run had no end_time: nothing counted toward max_wakes (a probe made 685 jumps in 4.8 s and moved a month). The rule: with no end_time, a jump counts as idle when nothing the step tracks is ahead (no alert or check for the agent, no user line, no held reply, no read) and every trigger with something ahead is a recurring time trigger with no fire in flight (none running, queued, waiting out a delay or hours, calling a contact or evaluating) and no one-off mark to come. IDLE_JUMPS_MAX (24) idle jumps in a row end the run as nothing_can_wake, which finish() records as finished_nothing_pending or stuck from what is pending; a world_only_repeats event names the triggers. A wake or a read restarts the count. So the rule can't cut short a run waiting on a known event (a held reply, a one-off trigger, a contact's delayed reply), and only ends one in which a recurring trigger has fired that many times running without waking the agent. With an end_time the run still goes on to it. The timeline keeps the first JUMPS_KEPT_PER_GAP (20) clock jumps between two wakes and folds the rest into one clock_jumps_folded entry (count, span, reasons, unsettled count), so a long gap can't grow it without bound; counts.clock_jumps counts them all. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… messages, stays small, counts malformed answers - A reply that never reached the agent (her last read's, or one still held when the run ended) looked delivered in the hook-shaped records: user_reads_at was set when she answered. Now a record gets user_reads_at only when a wake carries the reply (delivered: true); one never delivered has delivered: false, deliver_at and, if held at the end, why, so ML's asked_before_spending (first_reply over user_reads_at) can't count it as received. Checked with ML's process_checks.py (the surprise-30th copy the stress lanes used): first_reply is None and the row reads "before Linda first answered (never)", where it said she had answered at the held reply's time. - finish() takes the message_user messages no turn took (a turn that timed out after sending), within 60 s: they are in the graded response and conversation with their times, marked leftover in the timeline, and listed in pending_at_end as never read. - When the timeline can't be stored, the events kept in the record are cut down at any depth (strings, lists, nesting), as pending_at_end now is too; _bounded only trimmed top-level strings, and the end event carried the whole pending. - source_agent_per_turn_prompt_parts keeps each wake's text parts cut to 4000 characters, up to 400k in all, then a note; the timeline's wake events keep the full text. - counts.user_sim_malformed_answers counts reads whose answer had schema_errors. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n the timeline Part of keeping a long run's timeline bounded in memory (with the folded clock jumps): a gap in which triggers or contacts make thousands of app changes put every one of them in a single app_changes event. The event now keeps the first CHANGES_KEPT_PER_READ (200) and says how many it didn't keep; counts.app_changes still counts them all by who made them, and the judge's wake-by-wake view never showed more than 10 per wake. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…er, and a retried read counts as malformed The user sim plug-in retries once after a malformed answer. The read's timeline entry now saves retried and first_answer (stored with the full timeline, left out of the summary like raw), and user_sim_malformed_answers counts a read whose first answer was malformed even when the retry came back well formed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
🤖 Automated Claude routine — Env Pod PR Context Review. This is a bot, not Edgar Arakelyan; not a human review. Context from a sibling open PR rather than from this diff: #492 ("the gateway's control routes take the deploy's control token") puts the harness control routes behind the deploy's token by matching This PR adds five more routes on that same listener that its own card text calls the harness control path: Probably just a one-line addition to Generated by Claude Code |
…e run with error_type, as a failed one does send_and_wait returns any terminal state, and only failed was treated as an error, so a canceled or rejected turn (a wake or a nudge) went on as normal and graders scored the run. Any state but completed now ends the run (agent_turn_failed) and sets error_type (agent_turn_canceled / agent_turn_rejected unless the agent gave its own). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eck-in, not more ordinary wakes The clock runs during the agent's turns, so a turn can end past end_time. The loop only looked at end_time once nothing was due, so a reply or alert due then started an ordinary wake, and a back-and-forth could use up max_wakes without the final check-in ever being sent. Now, once the task's time is at or past end_time, the next wake is the final check-in, carrying whatever came due; a read that came due before end_time still goes first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…run ended _last_read read at the time the user would first have looked at the earliest unread message. A last turn that ran on with the clock running sent later messages after that time, so the read showed them before they were sent, and a reply could be dated before the question it answers. Now a read time earlier than the task's time moves to the first time she is free from then. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rver's stay_quiet Registering stay_quiet already skips a name a backing server owns, but removing the chat tools deleted stay_quiet from the tool manager whoever owned it, so a backing server's tool of that name was gone for the rest of the task. Removal now leaves it, as it does message_user and get_time. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…it sent before the run The user chat's take handed out every pending message, whoever sent it, so a message another role queued before the run was taken on the first wake as this agent's and went to the user sim and the graded chat. The take now accepts `from` (a role; without it, everything, as before) and leaves other roles' messages and stay_quiet calls queued. The step takes with its agent's role, and before the first wake sets aside what that role left before the run, recorded in the timeline as user_chat_before_run, so the first wake doesn't count it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ing note, not the final response In an always_on run only message_user texts reach the user; a wake's closing text is the agent's private note, which the user never sees and graders don't read. The trajectory viewer showed it as "Final Response". For a trajectory of an always_on step (its type in the task's steps, or its record under metadata.always_on), the block is now "Closing note (private, not sent to the user)", dashed, with no accent colour and muted text. prompt_agent and every other step look as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The line ("It's {when}. Any other date you see is the computer's clock, not the task's.", source "time") was for
Claude Code, which injects the real date. On the neutral harness each alert and each of the user's lines already
carries its time (the opening request included), and stress test 13 showed models read those UTC labels correctly
without it. get_time stays, through a deny-all too; the rules, wake reminder, stay_quiet, nudge, last-turn notice and
final check-in are unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e nudge stay on With stay_quiet on and the reminder and nudge off, Opus 5.5 and GPT 6.1 runs of Surprise 30th left no message in closing text (0 of 21 and 0 of 25 wakes). The rule stays in the agent's system prompt; a task can still turn the reminder on with wake_reminder: true. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd records the run; name the run's limits The take before the run, the start log and the first clock reads ran after the graded conversation was created but before the try whose finally calls finish(): a gateway that refused the take left the conversation active, with no end record and no prompt response. They now sit inside it. The inline limits get names: ASSISTANT_LIKE_MESSAGE_CHARS (1200), KEPT_NESTING_LEVELS (6), QUOTE_MIN_CHARS (8), LOGGED_TEXT_CHARS (200) and CALL_RETRY_MAX_SLEEP_SECONDS (20). No change in behaviour. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ts resumes_at A delay's wait published its end as since + seconds, but the sleeper worked out its own end from a second, later read of the clock. On a running clock the two differ by the virtual time between the reads (tens of microseconds at 60x), harmless while the clock keeps running. The always_on step now freezes the clock between turns (set_rate 0) and jumps it exactly to the reply's resumes_at, so the sleeper was left just short of its own end with nothing to move the clock: the settle timed out and the reply went at the next jump, hours of task time late. Working hours had the same gap. The sleeper now waits for the very mark the fire publishes: the delay's end as a Clock.elapsed() reading, or the hours' opening as a virtual time. The gateway's set-time, rate and clear routes also wake waiting fires (clock_changed), so one napping on a frozen clock follows the next turn's rate at once instead of up to 5 real seconds (5 virtual minutes at 60x) late. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, so a quick one isn't held behind a slow one - concurrent_fires ran a trigger's fires side by side only when every action was nl. A host's reply on a booking site is nl then notify, so its second fire queued: it drew its delay only once the first fire ended, and state showed no due time for it. The always_on step saw only the first fire's resumes_at and jumped there; in a live run the quicker reply (detected 18:01:58 + 752 s) landed with the slow one at 18:53:33, 39 task minutes late. - notify joins nl as side-by-side safe: neither reads and writes rows. Each fire's delay now runs from its own call and shows in waits / next_event_at; the executor call still takes turns on the conversation's lock. - Triggers with tool or permission actions still queue, as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| step = self.step | ||
| body = {"concurrent_fires": step.contact_replies_in_parallel} | ||
| if step.contact_timeout_seconds is not None: | ||
| body["executor_timeout_seconds"] = step.contact_timeout_seconds |
There was a problem hiding this comment.
contact_timeout_seconds doesn't reach contacts registered by register_env_triggers, so in the usual setup it has no effect.
The gateway applies executor_timeout_seconds only to executors registered without a timeout_seconds of their own (_executor_timeout in triggers.py, and the unset set built at registration). register_env_triggers always sends one: "timeout_seconds": self.executor_timeout_seconds, which defaults to 120 (register_env_triggers.py:67 and :243).
So with register_env_triggers then always_on, contacts are still cut off at 120 s, not this step's 300 s. To reproduce: register an executor with timeout_seconds: 120, then call /triggers/config with {"executor_timeout_seconds": 300}. The executor call still uses 120.
The simplest fix may be for register_env_triggers to send timeout_seconds only when the task sets executor_timeout_seconds (default None), so the gateway's default, or this step's, applies.
There was a problem hiding this comment.
Good catch, it was dead config. Removed contact_timeout_seconds in aebf480. The wait for contacts now reads each contact's own time limit from the gateway (what register_env_triggers sets with executor_timeout_seconds), and the run record shows the longest one.
| # An always_on step's prompt: what this verifier grades it from, set on the copy ``_for_always_on`` makes. | ||
| _always_on_wakes: Optional[str] = None | ||
|
|
||
| async def _for_always_on(self, context: TaskStepContext, record: dict) -> "RubricsVerifierTaskStep": |
There was a problem hiding this comment.
A follow-up for always_on grading. Rather than rubrics_verifier spotting always_on runs itself (_for_always_on, the copy and the always_on import), we'd like a rubrics_verifier subclass, AlwaysOnAgentVerifier (type always_on_agent_verifier):
- It grades an always_on prompt from the user's chat, the run context and the wake-by-wake summary, as
_for_always_ondoes today. - It refuses screenshot-citing output formats and
grade_up_to_turnwhen the task loads, not after the run. - The per-run wake summary goes into the judge-prompt builder rather than onto the shared step, so no copy is needed.
- An
is_rubrics_verifierhelper (a registry lookup plusissubclass, for step objects and stored dicts) lets code that checks the type string recognise subclasses.
Not blocking this PR. It's tracked internally as a follow-up, covering the framework step, the helper and hub support.
There was a problem hiding this comment.
Done in 7dda611: always-on runs are now graded by always_on_agent_verifier, a rubrics_verifier subclass. It grades from the user's chat, the run context and a wake-by-wake summary. When the task loads, it refuses screenshot-based formats, grade_up_to_turn and use_trajectory: true. A plain rubrics_verifier on an always-on prompt is refused at load. The hub PR (scaleapi/scaleapi#173960) treats the new type as a rubrics verifier.
…drop contact_timeout_seconds contact_timeout_seconds never reached the contacts register_env_triggers registers, since that step always sends its own timeout_seconds (executor_timeout_seconds, 120 by default). The setting is gone from the step, and with it the gateway's triggers config executor_timeout_seconds, which only the step used: config takes concurrent_fires alone again, and each executor keeps the limit it was registered with, as on main. The settle still needs the contacts' real limits: the triggers state now lists each executor's timeout_seconds (config.executor_timeouts), the run reads the longest when it starts, and caps every settle with it. The wake reserve and the time budget are worked out again from it, so a run whose contacts' limit leaves no room in the hub's job is refused before its first wake. When the task loads the step is checked with the gateway's default (120 s); a gateway that doesn't report its executors' limits gets that default too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…; rubrics_verifier no longer spots one rubrics_verifier used to recognise an always_on run itself (_for_always_on, a copy of the step and an always_on import). That moves into a subclass, AlwaysOnAgentVerifier (type always_on_agent_verifier): it grades the prompt from the user's chat, the run's context and the run wake by wake, as before, and refuses when the task loads what needs the wakes' trajectories: an output format that cites per-criterion screenshots, use_trajectory and grade_up_to_turn (a run's grade_up_to_turn is refused when it runs). - rubrics_verifier gains one override point, judge_prompt_sections(context, prompt_response), whose sections go into the judge prompt after the run context: the wake-by-wake summary is per run, so no copy of the step. With no override it adds nothing, so a plain rubrics_verifier grades every prompt exactly as before. - TaskStep.task_problems(steps), checked when a Task loads, refuses steps that don't go together: a plain rubrics_verifier on an always_on step's prompt (use always_on_agent_verifier), and an always_on_agent_verifier on another prompt step's prompt. - is_rubrics_verifier(step_or_config): a registry lookup plus issubclass, for steps and stored dicts, so code that would check the type string recognises the subclass. The explorer's rubric lookups and step colours, the outside agent rules and the field scope test know the new type. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
🤖 Automated Claude routine — Env Pod PR Context Review. This is a bot, not Edgar Arakelyan; not a human review. Context from
Two parts of this hunk may be worth dropping in the rebase rather than merging:
Worth knowing alongside it: open #631 adds a Not a review of this diff — just the half that is only visible from Generated by Claude Code |
One set_rate and one route: main's PUT /clock/set-rate and its _clock_write path replace this branch's /clock/rate. Rate 0 still freezes the clock (the card says so and sets minimum 0). Clock.set_rate keeps main's version/count/lived bookkeeping and current-time overflow guard. _change_clock wakes the fires waiting out virtual time on every write: clock_advanced already does for set_rate and advance, and clock_changed now does for set_time and clear. The executor read keeps this branch's 30 s of slack past the executor's own timeout, on top of main's FD-5333 fix. always_on raises StepContextError (#620) for an agent or user sim missing from the run's context. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| if self.read_at is not None and self.read_at <= now and self.unread \ | ||
| and (self.end is None or self.read_at < self.end): | ||
| await self.read(now) |
There was a problem hiding this comment.
When an agent sends a message before bedtime but its turn ends after bedtime, _next() calls read(now) immediately. It never checks whether the user is still awake or free, and it keeps the earlier read_mode. The user can therefore answer overnight or during a busy block, with the wrong reply delay. This changes when the assistant can act and can change its grade.
Recheck _available(now) before an overdue read, update its mode, and leave the read pending if the user is unavailable.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agent_env/task_step/task_steps/always_on.py
Line: 1013-1015
Comment:
**User answers while asleep**
When an agent sends a message before bedtime but its turn ends after bedtime, `_next()` calls `read(now)` immediately. It never checks whether the user is still awake or free, and it keeps the earlier `read_mode`. The user can therefore answer overnight or during a busy block, with the wrong reply delay. This changes when the assistant can act and can change its grade.
Recheck `_available(now)` before an overdue read, update its mode, and leave the read pending if the user is unavailable.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| def _latency(self, mode: str) -> timedelta: | ||
| rt = self.us.get("response_times") or {} | ||
| if mode == "now" or not rt: | ||
| return timedelta(0) | ||
| return timedelta(minutes=float(rt.get(mode, rt.get("free", 0)))) |
There was a problem hiding this comment.
Setting response_times without schedule silently removes every reply delay. _available() returns mode "now" when no schedule is set, and _latency() returns zero for that mode even when response_times.free is configured. For example, response_times={"free": 30} produces an immediate reply instead of waiting 30 task minutes. The assistant can then act earlier than the task settings allow.
Use the configured fallback delay when no schedule is supplied, or reject this combination rather than silently ignoring it.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agent_env/task_step/task_steps/always_on.py
Line: 932-936
Comment:
**Reply delays are ignored**
Setting `response_times` without `schedule` silently removes every reply delay. `_available()` returns mode `"now"` when no schedule is set, and `_latency()` returns zero for that mode even when `response_times.free` is configured. For example, `response_times={"free": 30}` produces an immediate reply instead of waiting 30 task minutes. The assistant can then act earlier than the task settings allow.
Use the configured fallback delay when no schedule is supplied, or reject this combination rather than silently ignoring it.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.…il the user is free; response_times needs a schedule When the agent's turn ran into the user's night or a busy block, her read went ahead at once in the mode it was scheduled in. Now it's rechecked at the time she'd read: she reads then if she's free, in that time's mode, else when she's next free. And user_sim refuses response_times without a schedule, which never held a reply. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Builds on #544 (its commit 11c8a8e is included here). Merge #544 first and this PR drops it. How the user sim behaves comes in its own PR, stacked on this one.
What
Two new step types.
always_onruns the agent under test as an always-on assistant:end_time, or when the user says done in a one-sitting task.user_simsays who the user is: description, time zone, awake and busy hours, how long they take to reply, and the agent that plays them.The agent reaches the user only through two new gateway tools:
message_user, andstay_quietfor when there's nothing worth saying. Its normal reply goes to a log the user never reads.Today, multi-day tasks are built with
prompt_agentplus a per-task hook script, and each task does it differently. Our stress test of ML's three tasks found:FD-5332 has the evidence.
Change
task_steps/always_on.py):register_env_triggerswithexecutor_timeout_seconds).task_steps/user_sim.py): the settings, and a plug-in interface (configure/setup/answer) for how the user behaves. A built-in default is included. The user sim's behaviour gets its own PR (FD-5380).message_user,stay_quietand the user chat (env/gateway/user_chat.py);clock/v1 set_rate.error_type;asked_before_spendingcheck reads, so it now grades instead of passing;rubrics_verifiersubclass,always_on_agent_verifier, grades an always_on prompt from the user's chat, the run context and a wake-by-wake summary, never every wake's trajectory. It refuses screenshot-citing output formats andgrade_up_to_turnwhen the task loads, and a plainrubrics_verifieron an always_on prompt is refused with a pointer to it.is_rubrics_verifier()recognises the subclass wherever code checks for a rubrics verifier.Behaviour changes for existing users
#544: the executor read timeout goes from 30 s to the executor's own limit (its own PR).running,waits,executor_calls,settings) and the card gains a feature,fires_in_state. Additive.prompt_agenttasks behave as before.Everything else is new code paths, used only by
always_onanduser_sim.Tests
test_mcp_cli_builder, a Python withoutclick).The checks cover:
ML's own graders ran too; their scores reflect the agent, not the step.
After the review fixes, everything was merged again with the plumbing and user sim PRs: 13,024 unit tests pass, with 1 known failure (
test_disk_recipe). A Tree Removal run at clock rate 1 then passed 37 of 37 checks ($1.75), confirming each fix:A full-week run of a parents' visit task (Opus 5.5, $20.92) then ran to its end time: every clock move settled, and contact replies arrived at their set times.
Worth a look
always_on.pyis the bulk. Its module docstring describes the loop in order; start there.set_raterather thanset_time.set_timere-arms the clock, which would push every recurring time trigger back on each turn.end_time: it ends after 24 clock jumps in a row where nothing that could wake the agent is ahead (IDLE_JUMPS_MAX). A run with anend_timealways goes on to it.post_turn_hooks. With nofile_artifact_versionit loads the latest.Linear: FD-5364, FD-5365, FD-5366, FD-5367, FD-5368, FD-5369, FD-5370, FD-5371, FD-4408.
🤖 Generated with Claude Code
Fix the two user-timing bugs before merging.
Fix with agent prompt
Summary
Adds
always_ontasks, simulated-user settings, gateway chat tools, contact settling, and chat-based grading.Diagram
%%{init: {'theme': 'neutral'}}%% flowchart TD A[Agent wake] --> B[Run clock during turn] B --> C[Freeze clock and settle contacts] C --> D{Next event} D -->|User read| E[Ask simulated user] E --> F[Hold reply until delivery time] F --> A D -->|Alert or scheduled check| A D -->|Future event| G[Advance clock] G --> D D -->|End time or budget| H[Final wake and last read] H --> I[Store chat and timeline]Reviews (19) · Last reviewed commit: "Merge origin/main into sarahchitty/fd-53..." · Reviewed by Greptile