Skip to content

feat(always_on): a step that runs an assistant over days of task time, with a simulated user (FD-5364) - #566

Open
sarah-chitty-1 wants to merge 55 commits into
mainfrom
sarahchitty/fd-5364-always-on-step
Open

sarah-chitty-1 wants to merge 55 commits into
mainfrom
sarahchitty/fd-5364-always-on-step

Conversation

@sarah-chitty-1

@sarah-chitty-1 sarah-chitty-1 commented Nov 9, 2026 •

Copy link
Copy Markdown
Collaborator

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_on runs the agent under test as an always-on assistant:
    • It wakes the agent when something happens: an alert from a trigger, the user's reply, a scripted user line, or a check the agent scheduled for itself.
    • Between wakes it moves the task clock straight to the next event, never past one.
    • It ends at end_time, or when the user says done in a one-sitting task.
  • user_sim says who the user is: description, time zone, awake and busy hours, how long they take to reply, and the agent that plays them.
    • The loop asks the user sim only when the agent has messaged them, at the time they would read it.

The agent reaches the user only through two new gateway tools: message_user, and stay_quiet for 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_agent plus a per-task hook script, and each task does it differently. Our stress test of ML's three tasks found:

  • events landing hours late;
  • 22% of contact replies cut off;
  • graders seeing only the last wake;
  • a spending check that passed without running.

FD-5332 has the evidence.

Change

  • The loop (task_steps/always_on.py):
    • wakes from trigger alerts, user replies, scripted lines and scheduled checks; anything due in the same minute arrives in one wake;
    • a clock that runs only during the agent's turns;
    • waits until contacts have actually replied before the next wake, up to each contact's own time limit (set on register_env_triggers with executor_timeout_seconds).
  • User sim (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).
  • Gateway:
    • message_user, stay_quiet and the user chat (env/gateway/user_chat.py);
    • the trigger engine reports each fire's progress, and can run contact replies side by side (opt-in);
    • a new clock call, clock/v1 set_rate.
  • Endings:
    • a final check-in at the end time, with a last-turn notice;
    • the user reads the wrap-up;
    • what was still pending is recorded;
    • an end marker with the reason; "finished" and "stuck" are told apart.
  • Grading:
    • the graded response is the user's whole chat with times, never the agent's private text;
    • failed turns set error_type;
    • each app change is tagged with the wake that made it;
    • each reply is linked to the request it answered;
    • reply-time records in the shape ML's existing asked_before_spending check reads, so it now grades instead of passing;
    • a rubrics_verifier subclass, 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 and grade_up_to_turn when the task loads, and a plain rubrics_verifier on an always_on prompt is refused with a pointer to it. is_rubrics_verifier() recognises the subclass wherever code checks for a rubrics verifier.
  • Limits:
    • a real-time budget so a run ends cleanly inside the hub's 8-hour job;
    • the run record keeps a short summary, with the full timeline in storage;
    • the process log carries no message text.

Behaviour changes for existing users

  • #544: the executor read timeout goes from 30 s to the executor's own limit (its own PR).
  • Trigger state gains fields (running, waits, executor_calls, settings) and the card gains a feature, fires_in_state. Additive.
  • A contact reply that completes just after its time limit is now kept instead of failing the fire.
  • Side-by-side contact replies are off unless a step turns them on.
  • When the clock moves, a contact reply waiting on a delay goes out at exactly its time, including on a clock frozen between turns. Replies that also alert the agent run side by side. Tasks with a clock at rate 0 and prompt_agent tasks behave as before.

Everything else is new code paths, used only by always_on and user_sim.

Tests

  • Unit tests: over 100 new across the loop, endings, grading, budget, clock, settling, user sim inputs and gateway tools. They use a real in-process gateway with a scripted agent and user sim.
  • Full unit suite: per folder, the same as clean main except 2 known failures (test_mcp_cli_builder, a Python without click).
  • End to end on a laptop, Opus 5.5 on the neutral harness, with the user sim and contacts played by agents:
Task Cost Wakes Ended Checks
Surprise 30th $9.18 26 end time 32/32
Tree Removal $14.13 80 wake cap 31/31
Holiday Rush (browser) $15.60 3 user done 29/29
Holiday season (a month) $18.16 47 end time 31/31

The checks cover:

  • the graded chat;
  • no private text reaching graders or the user;
  • every contact reply landing before the next wake;
  • the final check-in;
  • the record size;
  • thinking settings.

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:

  • the process log has no message text;
  • the clock is frozen between turns;
  • undelivered replies are marked;
  • the gateway's settings are put back at the end.

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

  • The size of the step: always_on.py is the bulk. Its module docstring describes the loop in order; start there.
  • The clock freeze uses the new set_rate rather than set_time. set_time re-arms the clock, which would push every recurring time trigger back on each turn.
  • Ending a run with no 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 an end_time always goes on to it.
  • The default time budget is 6.62 h at 600 s turns: budget plus one worst-case wake fits 8 h with 10 minutes spare.
  • The user sim plug-in loads from a file artifact, in the runner's process, like post_turn_hooks. With no file_artifact_version it 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

RetriggerView in GreptileConfidence Score: 3/5

Fix the two user-timing bugs before merging.

Fix All in CursorFindings

  1. P1 User answers while asleep ▶
  2. P1 Reply delays are ignored ▶
Fix with agent prompt
### Issue 1
src/agent_env/task_step/task_steps/always_on.py:1013-1015
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.

### Issue 2
src/agent_env/task_step/task_steps/always_on.py:932-936
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.

Summary

Adds always_on tasks, simulated-user settings, gateway chat tools, contact settling, and chat-based grading.

  • The always-on step wakes the assistant for events and advances simulated time between turns.
  • The assistant reaches the simulated user through a separate message tool and chat queue.
  • Trigger state reports work in progress, and contact replies can run side by side when enabled.
  • A user-sim step sets the user's profile and connects the always-on run to a simulated user agent.
  • Always-on runs are graded from the user's chat and a wake-by-wake run summary.

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]
Loading

Reviews (19) · Last reviewed commit: "Merge origin/main into sarahchitty/fd-53..." · Reviewed by Greptile

sarah-chitty-1 and others added 30 commits November 8, 2026 11:45
… 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>
sarah-chitty-1 and others added 2 commits November 9, 2026 03:43
… 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>
@sarah-chitty-1
sarah-chitty-1 requested a review from a team as a code owner November 9, 2026 18:12
Comment thread src/agent_env/env/gateway/gateway.py Resolved
…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>
@earakely-scale

Copy link
Copy Markdown
Collaborator

🤖 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 scope["path"] against a hardcoded set in the ASGI wrapper — CONTROL_ROUTES in src/agent_env/env/gateway/constants.py, which enumerates /tools/{enable,disable}, /triggers/{register,remove,clear}, /clock/{set-time,advance,clear} and /notifications/{config,take,clear}, and says everything else "stays open for now".

This PR adds five more routes on that same listener that its own card text calls the harness control path: /triggers/config (gateway.py:301), /clock/rate (:343), /user_chat/config (:432), /user_chat/take (:461), /user_chat/clear (:472). None of them appear in that frozenset, so whichever of the two branches merges second leaves them callable without the token by anything that can reach the gateway — which, per #492's own framing, includes the agent on the MCP url. Concretely: PUT /clock/rate {"virtual_seconds_per_real_second": 0} freezes the clock the always-on step is pacing (0 freezes, per the card description added here), and POST /user_chat/take drains the queue the harness reads. /user_chat/state is a read, so it lines up with /clock/state and /triggers/state, which #492 leaves open deliberately.

Probably just a one-line addition to CONTROL_ROUTES on whichever branch rebases last — flagging it because neither diff shows the other half.


Generated by Claude Code

sarah-chitty-1 and others added 7 commits November 9, 2026 10:52
…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>
sarah-chitty-1 and others added 4 commits November 9, 2026 12:41
…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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@earakely-scale earakely-scale Nov 12, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_on does today.
  • It refuses screenshot-citing output formats and grade_up_to_turn when 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_verifier helper (a registry lookup plus issubclass, 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.

@sarah-chitty-1 sarah-chitty-1 Nov 12, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@earakely-scale

Copy link
Copy Markdown
Collaborator

🤖 Automated Claude routine — Env Pod PR Context Review. This is a bot, not Edgar Arakelyan; not a human review.

Context from main rather than from this diff: the clock card block this PR edits moved on main after this branch's merge base, and set_rate already exists there at a different path.

  • This PR adds it as a new capability — the route @self._mcp.custom_route("/clock/rate", methods=["PUT"]) (src/agent_env/env/gateway/gateway.py:344) and the card entry "set_rate": {"method": "PUT", "endpoint": "/clock/rate", …} (src/agent_env/env/gateway/constants.py:229). At the merge base (0d171e0, Nov 9; the branch is 90 behind) the clock card carried no set_rate at all, so the diff reads as an addition rather than a rename.
  • 6344340 ("computers follow the clock through a watch, and clock writes wait for them", feat(gateway): computers follow the clock through a watch, and clock writes wait for them (FD-5471) #614, Nov 11) added one on main at /clock/set-rate: declared at constants.py:243, served by gateway.py:328, over a Clock.set_rate (clock.py:200) that keeps t0 and the generation so time triggers keep their marks — the semantics this PR's new description names.

Two parts of this hunk may be worth dropping in the rebase rather than merging:

  1. Trigger re-evaluation. This route calls self._trigger_engine.clock_changed() itself. On main every clock write goes through _clock_write → _change_clock (gateway.py:613, :633), which re-evaluates the time triggers on any change that keeps the generation — a rate change does — and answers held watches.
  2. The rest of the block. main's clock methods now carry wait_followers_s (_WAIT_FOLLOWERS) on set_time, set_rate, advance and clear, read at gateway.py:623 to wait for declared followers and answer 504 when they're behind, plus a watch method. Those sit as context lines in this hunk, so taking this side of constants.py would drop them from the card. The advertised path is the half worth watching: tst/unit/env/gateway/clock_watch_test.py:79 pins the card's set_rate to ("PUT", "/clock/set-rate"), so a card/route mismatch there fails loudly, but only in that one assertion.

Worth knowing alongside it: open #631 adds a set_env_clock_rate task step that reaches set_rate through the card (deployed.invoke(EXT_CLOCK_URI, "set_rate", …)), so whichever path the card ends up advertising is the one that step calls.

Not a review of this diff — just the half that is only visible from main. Nothing to do here if the rebase plan already covers it.


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>
Comment on lines +1013 to +1015
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

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.

Fix in Cursor Fix in Claude Code Fix in Codex

Comment on lines +932 to +936
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))))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

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.

Fix in Cursor Fix in Claude Code Fix in Codex

…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>

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